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ですが、適当なものに直しましょう。
その他
そのうち、メールアドレス変更時の処理も追加しておきます。登録時の確認キーとメールの処理を流用すれば、簡単に実現できるでしょう。(ただ、この場合はユーザレコードの作成日時ではなく、確認キーのレコードの作成日時を初めからチェックする必要があります。)