Laravel4.1、認証機能のサンプル
タグ: Laravel4
Laravelユーザーも増えてきましたので、認証機能を外部パッケージを使わずにバージョン4.1で組んでみると、こうなりますよというサンプルです。でも、もうほぼ実用です。コードを読んで学ぶタイプの方向けです。
実装する機能は、以下の通りです。
- ユーザー登録(メールで確認)
- ロールとURIによるアクセスコントロール
- パスワードリマインダー(パスワードリセット)
- Remember Me(オートログイン)
- ユーザー資格停止(サスペンド)
- モデルでアクセス、ユーザーのソフトデリート採用
Remember Me機能はLaravelの機能をそのまま使用しています。現状はセキュリティーの面も改善され、そのまま使用することができます。パスワードリマインダー(リセット)の機能も、Laravelの機能をそのまま使用しています。
さすがにコードを全部ルート定義に書くと、かえって読みづらいので、コントローラーにまとめて書いていますが、ひと目で読めるように「ファット・コントローラー」にしています。
テーブルの準備
usersテーブルのマイグレーション中、upメソッドは以下のようになります。
Schema::create( 'users', function( Blueprint $table ) { $table->increments( 'id' ); $table->string( 'username', 64 ); $table->string( 'email', 320 ); $table->string( 'password', 60 ); $table->smallInteger( 'active' )->default( 0 ); $table->smallInteger( 'suspended' )->default( 0 ); $table->smallInteger( 'role' )->default( 0 ); $table->string( 'remember_token' )->nullable(); $table->timestamps(); $table->softDeletes(); }
メールでの認証に利用する、認証コードを保持するテーブル(confirms)を別に作成します。ユーザーと1:1のリレーションをもたせます。
<?php use Illuminate\Database\Schema\Blueprint; use Illuminate\Database\Migrations\Migration; class CreateConfirms extends Migration { /** * Run the migrations. * * @return void */ public function up() { Schema::create( 'confirms', function( Blueprint $table ) { $table->increments( 'id' ); $table->unsignedInteger( 'user_id' ); $table->string( 'key' ); $table->timestamps(); $table->foreign( 'user_id' ) ->references( 'id' )->on( 'users' ) ->onDelete( 'cascade' ) ->onUpdate( 'cascade' ); } ); } ...
テーブル自身は単純ですが、外部キーで紐つけています。これで対応するユーザーが削除された場合は、自動的に削除するようにしています。
その他、パスワードリセッター用の準備は、ドキュメントにしたがって用意してください。
モデルの準備
ユーザーです。
<?php use Illuminate\Auth\UserInterface; use Illuminate\Auth\Reminders\RemindableInterface; class User extends Eloquent implements UserInterface, RemindableInterface { protected $guarded = [ 'id' ]; protected $hidden = array( 'password' ); protected $softDelete = true; public function setPasswordAttribute( $value ) { $this->attributes['password'] = Hash::make( $value ); } public function confirm() { return $this->hasOne( 'Confirm' ); } public function getAuthIdentifier() { return $this->getKey(); } public function getAuthPassword() { return $this->password; } public function getRememberToken() { return $this->remember_token; } public function setRememberToken( $value ) { $this->remember_token = $value; } public function getRememberTokenName() { return 'remember_token'; } public function getReminderEmail() { return $this->email; } }
パスワードをミューティターを利用し、セット時に自動的にハッシュにしている他は、特別な部分はありません。
続いて、認証キーの管理テーブルのモデルです。
<?php class Confirm extends Eloquent { protected $fillable = [ 'key' ]; public function user() { return $this->belongsTo('User'); } }
こちらも特別な部分はありません。
ルートとフィルター
ルート定義です。
<?php // パターン Route::when( '*', 'csrf', [ 'post' ] ); Route::when( 'admin/*', 'admin' ); Route::when( 'dashboard/*', 'auth' ); Route::pattern('id', '[0-9]+'); // 各ルート Route::get( '/', [ 'as' => 'home', 'uses' => 'FixPageController@home' ] ); Route::get( 'register', ['as' => 'register-form', 'uses' => 'AuthController@showRegisterForm' ] ); Route::post( 'register', ['as' => 'handle-register', 'uses' => 'AuthController@handleRegister' ] ); Route::get( 'confirm', ['as' => 'confirm-form', 'uses' => 'AuthController@showConfirmForm' ] ); Route::post( 'confirm', ['as' => 'handle-confirm', 'uses' => 'AuthController@handleConfirm' ] ); Route::get( 'login', ['as' => 'login-form', 'uses' => 'AuthController@showLoginForm' ] ); Route::post( 'login', ['as' => 'handle-login', 'uses' => 'AuthController@handleLogin' ] ); Route::get( 'logout', ['as' => 'logout', 'uses' => 'AuthController@handleLogout' ] ); Route::get( 'remainder', ['as' => 'reminder-form', 'uses' => 'RemindersController@showReminderForm' ] ); Route::post( 'remainder', ['as' => 'handle-reminder', 'uses' => 'RemindersController@handleReminder' ] ); Route::get( 'reset/{token}', ['as' => 'reset-form', 'uses' => 'RemindersController@showResetForm' ] ); Route::post( 'reset/{token}', ['as' => 'handle-reset', 'uses' => 'RemindersController@handleReset' ] );
routes.phpです。特別な部分はありません。初めの4パターンで定義している内容は、以下の通りです。
- 全POSTルートにCSRFフィルターをかける
- 'admin/'で始まるURIには'admin'フィルターをかける
- 'dashboard/'で始まるURIには、'auth'フィルターをかける
- ルート指定時の'id'プレースフォルダーに、整数の束縛を付ける
フィルターは、以下の通りです。
<?php App::before( function($request) { } ); App::after( function($request, $response) { } ); Route::filter( 'auth', function() { if( Auth::guest() ) { return Redirect::guest( 'login' ) ->withMessage( 'ログインしてください。' ); } } ); Route::filter( 'admin', function() { if( Auth::guest() || Auth::user()->role < 100 ) { return Redirect::guest( 'login' ) ->withMessage( '管理者の権限がありません。' ); } } ); Route::filter( 'csrf', function() { if( Session::token() != Input::get( '_token' ) ) { throw new Illuminate\Session\TokenMismatchException; } } );
ユーザーのロールは、通常のユーザーが'0'、管理者は'100'としています。
ビュー
省略します。
ユーザー登録では、パスワードの確認を省きました。最近は省くほうが主流です。ですから登録してもらうのは、ユーザー名、パスワード、メールアドレスです。
認証は、メールアドレスとパスワードで行います。
ユーザー登録の確認と、パスワードリセットの確認は、メール中からのリンクから飛ばされてくる前提です。今回実装したユーザー登録では、認証コードはクエリー文字列として送り、Laravelの機能をそのまま利用した、パスワードリセッターではURIに含まれています。(仕様)
ユーザー登録の確認のメールのみ、紹介します。
<!DOCTYPE html> <html lang="en-US"> <head> <meta charset="utf-8"> </head> <body> <h2>ユーザー登録の確認</h2> <div> <p>{{ $username }}様、</p> <p>XXXXに登録いただき、ありがとうございます。</p> <p>登録したアカウントを有効にするためには、次のリンクをクリックしてください。</p> <p>{{ URL::to( 'confirm?key='.$key ) }}</p> <p>リンク先で、再度メールアドレスを指定いただきますと、登録確認完了です。</p> <p>なお、このリンクは24時間有効です。それ以降は、お手数ですが再度登録し直してください。</p> <p>もし、XXXXXに登録していただいた覚えがない場合は、リンクをクリックしないよう、お願いいたします。24時間後、自動的に登録された情報(メールアドレス)は、当サイトから削除されますので、ご安心ください。</p> <p>以上、よろしくお願いいたします。</p> <p>XXXXXX管理者:川瀬 裕久(master@xxxxxxx.com)</p> </div> </body> </html>
コントローラー
リマインダーのため、コマンドで作成する、RemindersControllerは省略します。
AuthContoroller.phpです。
<?php use Carbon\Carbon; class AuthController extends BaseController { public function showRegisterForm() { return View::make( 'auth.register' ); } public function handleRegister() { $rules = [ 'username' => [ 'required' ], 'email' => [ 'required', 'email', 'unique:users' ], 'password' => [ 'required', 'min:6' ], ]; $inputs = Input::only( ['username', 'email', 'password' ] ); $val = Validator::make( $inputs, $rules ); if( $val->fails() ) { return Redirect::route( 'register-form' ) ->withErrors( $val ) ->withInput(); } DB::beginTransaction(); try { $user = User::create( $inputs ); $confirmKey = Str::random( 16 ); $user->confirm()->create( ['key' => $confirmKey ] ); } catch( Exception $e ) { DB::rollBack(); return App::abort( '500', 'データベースが不調です。確認キーの生成に失敗しました。' ); } DB::commit(); Mail::queue( 'emails.auth.confirm', [ 'username' => $inputs['username'], 'key' => $confirmKey ], function($message) use($inputs) { $message->subject( 'xxxxxx登録確認' ); $message->to( $inputs['email'] ); } ); return Redirect::route( 'home' ) ->withMessage( '登録確認メールを送信しました。24時間以内に、中の確認リンクをクリックしてください。' ); } public function showConfirmForm() { return View::make( 'auth.confirm' ); } public function handleConfirm() { $rules = [ 'email' => [ 'required', 'email', 'exists:users' ], 'key' => [ 'required', 'size:16' ], ]; $inputs = Input::only( [ 'email', 'key' ] ); $val = Validator::make( $inputs, $rules ); if( $val->fails() ) { return Redirect::route( 'confirm-form' ) ->withInput() ->withErrors( $val ); } $user = User::whereEmail( $inputs['email'] )->first(); if( $user->active == 1 ) { return Redirect::home() ->withMessage( '既に有効になっています。ログインしてご利用ください。' ); } $expired_at = with( new Carbon( $user->created_at ) )->addHours( 24 ); if( $expired_at->lt( Carbon::now() ) ) { DB::transaction( function() use($user) { $user->forceDelete(); // 外部キーの指定でconfirmも同時削除 } ); return Redirect::route( 'register-form' ) ->withWarning( '仮登録から24時間過ぎています。恐れ入りますが、再度ユーザー登録し直してください。' ); } $confirm = $user->confirm()->first(); if( $confirm->key != $inputs['key'] ) { return Redirect::route( 'confirm-form' ) ->withInput() ->withWarning( '入力されたキーが一致しません。' ); } DB::beginTransaction(); try { $user->active = 1; $user->save(); $confirm->delete(); } catch( Exception $e ) { DB::rollBack(); return App::abort( '500', 'データベースが不調です。本登録に失敗しました。' ); } DB::commit(); return Redirect::home() ->withMessage( '登録を確認しました。ログインいただけます。' ); } public function showLoginForm() { return View::make( 'auth.login' ); } public function handleLogin() { $rules = [ 'email' => [ 'required', 'email' ], 'password' => [ 'required' ], ]; $inputs = Input::only( ['email', 'password' ] ); $val = Validator::make( $inputs, $rules ); if( $val->fails() ) { return Redirect::route( 'login-form' ) ->withInput() ->withErrors( $val ); } if( !Auth::attempt( $inputs, (Input::get( 'remember', '0' ) == '1' ) ) ) { return Redirect::route( 'login-form' ) ->withInput() ->withWarning( 'メールアドレス/パスワードが一致しません。' ); } if( Auth::user()->active == 0 ) { Auth::logout(); return Redirect::home() ->withWarning( 'ユーザー登録が終了していません。登録確認メール中のリンクをクリックし、確認作業を初めてください。' ); } if( Auth::user()->suspended == 1 ) { Auth::logout(); return Redirect::home() ->withDanger( 'このユーザーの利用資格は停止されています。' ); } return Redirect::intended( Auth::user()->role == 100 ? route( 'admin-panel' ) : '/' ) ->withMessage( 'ログインしました。' ); } public function handleLogout() { Auth::logout(); return Redirect::home() ->withMessage( 'ログアウトしました。' ); } }
太っていますが、それでも二百行足らずです。
リダイレクト時にwithで$message、$warning、$dangerに文字列をセッションにフラッシュとして保存すると、リダイレクト先でメッセージとして表示する仕組みにしています。Redirect::back()は、大抵の場合問題にならないと思いますが、できるだけ避け、Redirect::route()でルートに付けた名前で指定しています。
これ以外を説明するのはちょっと億劫ですので、コメント入りバージョンをご覧ください。
<?php use Carbon\Carbon; class AuthController extends BaseController { // 今回のコードでは、フォームの出力は // show....Formというアクション名を付けています。 public function showRegisterForm() { return View::make( 'auth.register' ); } // フォームの処理はhandle...という名前を付けています。 public function handleRegister() { $rules = [ 'username' => [ 'required' ], // 日本語の場合、漢字一文字があるため 'email' => [ 'required', 'email', 'unique:users' ], 'password' => [ 'required', 'min:6' ], // 長さはリマインダーと合わせてある ]; $inputs = Input::only( ['username', 'email', 'password' ] ); $val = Validator::make( $inputs, $rules ); if( $val->fails() ) { return Redirect::route( 'register-form' ) ->withErrors( $val ) ->withInput(); } // トランザクションの開始、usersテーブルと、紐ついたconfirmsテーブルを // 同時に更新するため。 // また、エラー発生時に500にするため、DB::transaction()を使用していない。 DB::beginTransaction(); try { $user = User::create( $inputs ); // Str::random()の実装は調べていない。 // 乱数生成のアルゴリズムに問題がないか、要チェック // 実用にするなら、16ではなく、もっと増やしたほうが良い $confirmKey = Str::random( 16 ); $user->confirm()->create( ['key' => $confirmKey ] ); } catch( Exception $e ) { DB::rollBack(); return App::abort( '500', 'データベースが不調です。確認キーの生成に失敗しました。' ); } DB::commit(); // メールは基本queue()で送信する。デフォルトのsyncドライバーだと、 // send()コマンドとほぼ同じ。(結果が取れないのが弱点か。) // 本格的にキューが使用したくなったら、設定ファイルを変更して // 対応できる。 Mail::queue( 'emails.auth.confirm', [ 'username' => $inputs['username'], 'key' => $confirmKey ], function($message) use($inputs) { $message->subject( 'xxxxxxx登録確認' ); $message->to( $inputs['email'] ); } ); // 'home'と名前を付けたルートへリダイレクトする。 // 今回は'/'へリダイレクト。 return Redirect::home() ->withMessage( '登録確認メールを送信しました。24時間以内に、中の確認リンクをクリックしてください。' ); } public function showConfirmForm() { return View::make( 'auth.confirm' ); } public function handleConfirm() { // メールアドレスは、usersテーブルに登録されていることをバリデートする。 $rules = [ 'email' => [ 'required', 'email', 'exists:users' ], 'key' => [ 'required', 'size:16' ], ]; $inputs = Input::only( ['email', 'key' ] ); $val = Validator::make( $inputs, $rules ); if( $val->fails() ) { return Redirect::route( 'confirm-form' ) ->withInput() ->withErrors( $val ); } // バリデーションで、メールアドレスは存在していることを確認済み // そのため、必ずヒットするので、クエリー後のチェックは省略している。 // ただ、バッチ処理の削除とバッティングする可能性があるので、 // より良い方法としては、共有でレコードロックをかけ、 // バッチ処理は専有でテーブルロックをかける方が良いだろう。 $user = User::whereEmail( $inputs['email'] )->first(); // 確認済みのチェック if( $user->active == 1 ) { return Redirect::home() ->withMessage( '既に有効になっています。ログインしてご利用ください。' ); } // 認証キーの有効期限チェック。本来はconfirmsテーブルのレコードで調べるが、 // ユーザーと同時に作成されるので、ユーザー作成日付を代用している。 // (アクセスが増えるので、遅らせたい。) // Carbonを利用し、24時間後のインスタンスを生成している。 // withはLaravelのヘルパー、生成したインスタンスにメソッドチェーンを // 繋げたい場合に利用する。 // テーブルのタイムスタンプの値は、直接Carbonに渡して、その日時の // インスタンスが生成できます。 $expired_at = with( new Carbon( $user->created_at ) )->addHours( 24 ); // $expired_at < Carbon::now() をチェック if( $expired_at->lt( Carbon::now() ) ) { // 本当は失敗時は500にしたほうが良い :D // 今回は手抜きでtransaction()を使用。 DB::transaction( function() use($user) { // 外部キーで指定しているため、 // 対応するconfirmsのレコードも同時削除 $user->forceDelete(); } ); return Redirect::route( 'register-form' ) ->withWarning( '仮登録から24時間過ぎています。恐れ入りますが、再度ユーザー登録し直してください。' ); } // 関連付けから、対応するconfirmsレコードを取得 // 事前にEagerロードする手もあるが、ユーザーが有効でない場合、 // そのクエリーが一回分無駄になるため、ここまで遅らせた。 $confirm = $user->confirm()->first(); if( $confirm->key != $inputs['key'] ) { return Redirect::route( 'confirm-form' ) ->withInput() ->withWarning( '入力されたキーが一致しません。' ); } // ユーザーを有効(active=1)にし、同時に // 対応するconfirmsレコードを削除 DB::beginTransaction(); try { $user->active = 1; $user->save(); $confirm->delete(); } catch( Exception $e ) { DB::rollBack(); return App::abort( '500', 'データベースが不調です。本登録に失敗しました。' ); } DB::commit(); return Redirect::home() ->withMessage( '登録を確認しました。ログインいただけます。' ); } public function showLoginForm() { return View::make( 'auth.login' ); } public function handleLogin() { // 登録時にパスワード長はエラーメッセージで表示されるが、 // ヒントになるため、ここでは積極的に出さない。 $rules = [ 'email' => [ 'required', 'email' ], 'password' => [ 'required' ], ]; $inputs = Input::only( ['email', 'password' ] ); $val = Validator::make( $inputs, $rules ); if( $val->fails() ) { return Redirect::route( 'login-form' ) ->withInput() ->withErrors( $val ); } // 未有効(active=0)と、アカウント停止(suspended=1)を // この認証の時点で弾くこともできるが、エラーメッセージを分けて表示するため、 // メールアドレスとパスワードだけで認証する。 // ログインページのremembeチェックボックスにチェックが付けられたら(1)、 // Remember Me(オートログイン)を有効にする if( !Auth::attempt( $inputs, (Input::get( 'remember', '0' ) == '1' ) ) ) { return Redirect::route( 'login-form' ) ->withInput() ->withWarning( 'メールアドレス/パスワードが一致しません。' ); } // ユーザー登録の確認が済んでいるかチェック if( Auth::user()->active == 0 ) { Auth::logout(); return Redirect::home() ->withWarning( 'ユーザー登録が終了していません。登録確認メール中のリンクをクリックし、確認作業を初めてください。' ); } // ユーザーの利用資格が停止されていないかチェック if( Auth::user()->suspended == 1 ) { Auth::logout(); return Redirect::home() ->withDanger( 'このユーザーの利用資格は停止されています。' ); } // 認証成功! // ログインページを表示されたきっかけが、'auth'フィルターに // 引っかかった場合のときは、そのURIにログイン後にアクセス可能であれば、 // もとのアクセスURIへリダイレクトされる。それ以外の場合、 // 通常ユーザー(roll=0)はルート(/)、管理者(roll=100)は // 管理者パネルへリダイレクトする。 return Redirect::intended( Auth::user()->role == 100 ? route( 'admin-panel' ) : '/' ) ->withMessage( 'ログインしました。' ); } public function handleLogout() { Auth::logout(); return Redirect::home() ->withMessage( 'ログアウトしました。' ); } }
コマンド
登録したが、24時間経っても確認作業を行わないユーザーを削除するバッチ処理が必要となります。通常はcronなどで、外部から実行することになるでしょう。
コマンドのベースとして、利用しているクラスです。
<?php use Illuminate\Console\Command; use Symfony\Component\Console\Formatter\OutputFormatter; use Symfony\Component\Console\Formatter\OutputFormatterStyle; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; /** * Handle exception and internationalize messages * for Laravel & Symfony Command class. */ class BaseCommand extends Command { /** * Override run method to internationalize error message. * * @param \Symfony\Component\Console\Input\InputInterface $input * @param \Symfony\Component\Console\Output\OutputInterface $output * @return int Return code */ public function run( InputInterface $input, OutputInterface $output ) { // Set extra colors // The most problem is $output->getFormatter() don't work. // So create new formatter to add extra color. $formatter = new OutputFormatter( $output->isDecorated() ); $formatter->setStyle( 'red', new OutputFormatterStyle( 'red', 'black' ) ); $formatter->setStyle( 'green', new OutputFormatterStyle( 'green', 'black' ) ); $formatter->setStyle( 'yellow', new OutputFormatterStyle( 'yellow', 'black' ) ); $formatter->setStyle( 'blue', new OutputFormatterStyle( 'blue', 'black' ) ); $formatter->setStyle( 'magenta', new OutputFormatterStyle( 'magenta', 'black' ) ); $formatter->setStyle( 'yellow-blue', new OutputFormatterStyle( 'yellow', 'blue' ) ); $output->setFormatter( $formatter ); try { $result = parent::run( $input, $output ); } catch( \RuntimeException $e ) { // All error messages were hard coded in // Symfony/Component/Console/Input/Input.php if( $e->getMessage() == 'Not enough arguments.' ) { $this->error( '引数が足りません。' ); } elseif( $e->getMessage() == 'Too many arguments.' ) { $this->error( '引数が多すぎます。' ); } elseif( preg_match( '/The "(.+)" option does not exist./', $e->getMessage(), $matches ) ) { $this->error( $matches[1].'オプションが足りません。' ); } else { $this->error( $e->getMessage() ); } $result = 1; } return $result; } }
今回作成したコマンドはこちらです。
<?php use Symfony\Component\Console\Input\InputOption; /** * Simple deploy Artisan command. * * You can change command by config file setting. */ class BatchCommand extends BaseCommand { /** * The dummy console command name. * This will be replaced by config setting. * * @var string */ protected $name = 'batch'; /** * The dummy console command description. * This will be replaced by language file setting. * * @var string */ protected $description = 'バッチ処理実行ツール'; /** * Execute the console command. * * Return value will be execute code of this command. * So don't return ture/false. It must be integer. * * @return integer Return Code. 0: Terminated successfully. */ public function fire() { $args = array_merge( $this->option(), $this->argument() ); if( $args['sweep-confirm'] ) { $affected = User::whereActive( '0' ) ->where( DB::raw( 'created_at + INTERVAL 24 HOUR' ), '<', DB::raw( 'now()' ) ) ->forceDelete(); $this->line( 'ユーザーレコードを<green>'.$affected.'件</green>削除しました。' ); } if( $args['purge-cache'] ) { Cache::flush(); $this->line( '全キャッシュをパージしました。' ); } return 0; } /** * Get the console command arguments. * * @return array */ protected function getArguments() { return [ ]; } /** * Get the console command options. * * @return array */ protected function getOptions() { return [ [ 'purge-cache', 'p', InputOption::VALUE_NONE, '全キャッシュを削除する。', null ], [ 'sweep-confirm', 's', InputOption::VALUE_NONE, '期限切れ登録確認キーレコードを削除する。', null ], ]; } }
-sで24時間経過しているが、有効になっていないユーザーをまとめて削除しています。ついでに、-pオプションで、全キャッシュをパージする機能も付けています。
コマンドを有効にするには、登録する必要があります。
厳密に言えば、ユーザー削除の場合、トランザクションを利用する必要があります。更に、削除前にユーザーテーブルをロックしておく必要もあるでしょう。それでもユーザー側で、(最悪であるが最善でもある、処理の初めから終わりまで)ロックをかけているわけではないため、1,コントローラーで24時間経ったレコード発見、2.バッチで24時間経ったレコード削除、3.コントローラー側が削除しようとしたけど、見つからなかった、という事態が起こり得ます。(レコードロックのことをすっかり忘れてしまいました。:D)
システムの運用も考慮すれば、バッチ中はシステムをメンテモードにし、バッチ処理を行い、終了後に再開するというのが、安全です。レコード更新の遅れも考え、メンテモードにし、若干時間を置き、削除処理し、すぐ再開である程度リスクは回避できそうです。
もしくは、確率的に多少の不具合が発生する可能性を受け入れてしまえば、コードはシンプルになります。ここらへんは実装の手間(コスト)と安全性を天秤にかけ、決定する部分です。今回のコードは、ある程度のリスクは受け入れ、簡単にコードできる範囲に収めてあります。
または、Laravelにはプライオリティーでコントロールでき、複数のキューを取り扱う機能がついていますので、これを利用しDBアクセスをキュー管理するかでしょう。(多分、テーブルロックよりはオーバーヘッドはかかりますが、キューの順番が入れ替わらず、かつプライオリティーが正しく動作していれば、安全な方法です。)
まあ、そこら辺はLaravelに限定した話でもないですし、よく考えましょうということで、おしまいにしましょう。
コマンド実行はもちろん、'php artisan batch オプション'となります。コマンドの名前はbatchですが、適当なものに直しましょう。
その他
そのうち、メールアドレス変更時の処理も追加しておきます。登録時の確認キーとメールの処理を流用すれば、簡単に実現できるでしょう。(ただ、この場合はユーザレコードの作成日時ではなく、確認キーのレコードの作成日時を初めからチェックする必要があります。)