初めてのMFA

Google Authenticator等で利用できるTOTPを扱うライブラリ(PragmaRX\Google2FA\Google2FA)を使いながらやっていく。

web.php
Route::get('/login', 'Auth\EmployeeLoginController@showLoginForm')->name('login'); // form表示
Route::post('/login', 'Auth\EmployeeLoginController@login')->name('login.post'); // 処理

Route::middleware('employee.auth')->group(function () {
    // ログアウト処理
    Route::post('/logout', 'Auth\EmployeeLoginController@logout')->name('logout');
    
    // 2段階認証が完了している場合にのみ利用可能なアクション
    // ......
    
    // 更に機能別Permissionの有無で許可するアクション(別件)
    Route::middleware('permission:Updatearsbackdate')->group(function () {
        // ......
    });
});
Route::middleware('pre.auth')->group(function () {
    // 2段階認証に登録途中である場合に利用可能なアクション
    Route::get('/mfa/setup', 'Auth\MfaController@setup')->name('mfa.setup'); // form表示
    Route::post('/mfa/setup', 'Auth\MfaController@confirm')->name('mfa.confirm'); // 処理
    Route::get('/mfa/verify', 'Auth\MfaController@verify_form')->name('mfa.verifyForm'); // form表示
    Route::post('/mfa/verify', 'Auth\MfaController@verify')->name('mfa.verify'); // 処理
});
App/Http/Kernel.php
protected $routeMiddleware = array(
    // 既存記述
    // ......
    
    // 追記
    'employee.auth' => \App\Http\Middleware\AuthenticateEmployee::class,
    'pre.auth' => \App\Http\Middleware\PreAuthenticateEmployee::class,
    'permission' => \App\Http\Middleware\CheckPermission::class,
);
App/Http/Middleware/AuthenticateEmployee.php
public function handle($request, Closure $next)
{
    // login セッションを持っていなければログイン画面にリダイレクト
    if (! $request->session()->has('login')) {
        return redirect()->route('login');
    }
    // login セッションがあるので通過
    return $next($request);
}
App/Http/Middleware/PreAuthenticateEmployee.php
public function handle($request, Closure $next)
{
    if (! $request->session()->has('pre_auth')) {
        return redirect()->route('login');
    }
    
    return $next($request);
}

正直 Middleware のお作法はよく理解できていない。

別件:Permissionが無い場合に 403 を返す。引数 $permission で与えられた文字 が permissions セッションに存在するかを見る。Routeの記法とも関係があるはず。

App/Http/Middleware/CheckPermission.php
public function handle($request, Closure $next, $permission)
{
    $permissions = $request->session()->get('permissions', []);
    
    if (! in_array($permission, $permissions, true)) {
        abort(403);
    }
    
    return $next($request);  
}

画面(View)上にQRコードを表示するのには以下より qrcode.js を拝借した。
Kazuhiko Arase's Workplace
GitHub - kazuhikoarase/qrcode-generator: QR Code Generator implementation in JavaScript, Java and more. · GitHub

QRコードに適用する文字列(DOM上にある)を元に、qrcode.js も使いつつQRコードを生成する。
今回はビルド工程を挟まず直接配信するため、qrcode.js は public/js 配下に配置した。

public/js/mfa-setup.js
document.addEventListener('DOMContentLoaded', function () {
    const qrElement = document.getElementById('qrcode');
    
    if (!qrElement) {
        return;
    }
    
    const otpAuthUrl = qrElement.dataset.otpAuthUrl;
    
    if (!otpAuthUrl) {
        return;
    }
    
    const qr = qrcode(0, 'M');
    
    qr.addData(otpAuthUrl);
    qr.make();
    
    qrElement.innerHTML = qr.createSvgTag({
        cellSize: 2,
        margin: 4,
        scalable: true
    });
});
Controllers/Auth/EmployeeLoginController.php
namespace App\Http\Controllers\Auth;

use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;

class EmployeeLoginController extends Controller
{
    /**
     * コンストラクタ
     */
    public function __construct()
    {
        $this->applied_employee_ids = array(
            // テーブル管理した方が良いが今は配列で行く
            123
            , 456
            // ......
        );
    }

    public function showLoginForm()
    {
        return view('auth.employee-login');
    }

    public function login(Request $request)
    {
        $id = $request->input('id');
        $pw = $request->input('pw');
        
        if ($id === null || $pw === null) {
            return back()
                ->withErrors([
                    'login' => 'Required.',
                ])
                ->withInput();
        }
        
        $employee = DB::table('table', 'tbl')
            ->select(
                'tbl.employee_id',
                'tbl.employee_name'
            )
            ->where('tbl.employee_id', $id)
            ->where('tbl.password', $pw)
            ->whereIn('tbl.employee_id', $this->applied_employee_ids)
            ->first();
        
        if ($employee === null) {
            return back()
                ->withErrors([
                    'login' => 'Invalid ID or Password.',
                ])
                ->withInput();
        }
        
        $request->session()->regenerate();
        
        $request->session()->put('pre_auth', [
            'id' => $employee->employee_id,
            'name' => $employee->employee_name,
        ]);
        
        $mfa = DB::table('table')
            ->where('employee_id', $employee->employee_id)
            ->first();
        
        if ($mfa === null or $mfa->confirmed_dt === null) {
            return redirect()->route('mfa.setup');
        }
        return redirect()->route('mfa.verifyForm');
    }

    public function logout(Request $request)
    {
        $request->session()->invalidate();
        $request->session()->regenerateToken();
          
        return redirect()->route('login');  
    }  
}
Controllers/Auth/MfaController.php
namespace App\Http\Controllers\Auth;

use App\Http\Controllers\Controller;
use App\Services\PermissionService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use PragmaRX\Google2FA\Google2FA;

class MfaController extends Controller
{
    private $permissionService;
    public function __construct(PermissionService $permissionService)
    {
        $this->permissionService = $permissionService;
    }

    /**
     * 初回MFA登録画面
     */
    public function setup(Request $request)
    {
        $preAuth = $request->session()->get('pre_auth');
        
        $mfa = DB::table('tbl')
            ->where('employee_id', $preAuth['id'])
            ->first();
        
        // 既にMFA登録済みなら、setupではなく通常の認証画面へ。
        if ($mfa !== null && $mfa->confirmed_dt !== null) {
            return redirect()->route('mfa.verifyForm');
        }
        
        // 初回ならSecretを生成して保存する。
        if ($mfa === null) {
            $google2fa = new Google2FA();
            $secret = $google2fa->generateSecretKey();
            
            DB::table('tbl')
                ->insert([
                    'employee_id' => $preAuth['id'],
                    'totp_secret' => encrypt($secret),
                    'confirmed_dt' => null,
                    'entry_dt' => now(),
                    'update_dt' => now(),
                ]);
            
            $mfa = DB::table('tbl')
                ->where('employee_id', $preAuth['id'])
                ->first();
        }
        
        $secret = decrypt($mfa->totp_secret);
        $google2fa = new Google2FA();
        
        $otpAuthUrl = $google2fa->getQRCodeUrl(  
            'My Tool',
            (string) $preAuth['id'],
            $secret
        );
        
        return view('auth.mfa-setup', [
            'employee_name' => $preAuth['name'],
            'secret' => $secret,
            'otpAuthUrl' => $otpAuthUrl,
        ]);
    }

    /**
     * 初回MFA登録時の6桁コード確認
     */
    public function confirm(Request $request)
    {
        $request->validate([
            'code' => ['required', 'regex:/^[0-9]{6}$/'],
        ]);
        
        $preAuth = $request->session()->get('pre_auth');
        
        $mfa = DB::table('tbl')
            ->where('employee_id', $preAuth['id'])
            ->first();
        
        if ($mfa === null) {
            return redirect()->route('mfa.setup');
        }
        
        // 既に登録済みならsetup確認をやり直させない。
        if ($mfa->confirmed_dt !== null) {
            return redirect()->route('mfa.verifyForm');
        }
        
        $secret = decrypt($mfa->totp_secret);
        
        $google2fa = new Google2FA();
        
        if (! $google2fa->verifyKey($secret, $request->input('code'))) {
            return back()
                ->withErrors([
                    'mfa' => 'Invalid verification code.',
                ]);
        }
        
        // 実際にAuthenticatorが正しいコードを生成できたのでここで初めて登録済みにする。
        DB::table('tbl')
            ->where('employee_id', $preAuth['id'])
            ->update([
                'confirmed_dt' => now(),
                'update_dt' => now(),
            ]);
        
        return $this->completeLogin($request);
    }

    /**
     * 登録済みユーザー用MFA入力画面
     */
    public function verify_form(Request $request)
    {
        $preAuth = $request->session()->get('pre_auth');
        
        $mfa = DB::table('tbl')
            ->where('employee_id', $preAuth['id'])
            ->first();
        
        // MFA情報が無い、または初回登録未完了ならsetupへ。
        if ($mfa === null || $mfa->confirmed_dt === null) {
            return redirect()->route('mfa.setup');
        }
        
        return view('auth.mfa-verify', [
            'employeeName' => $preAuth['name'],
        ]);
    }

    /**
     * 登録済みユーザーのMFA確認
     */
    public function verify(Request $request)
    {
        $request->validate([
            'code' => ['required', 'regex:/^[0-9]{6}$/'],
        ]);
        
        $preAuth = $request->session()->get('pre_auth');
        
        $mfa = DB::table('tbl')
            ->where('employee_id', $preAuth['id'])
            ->first();
        
        if ($mfa === null || $mfa->confirmed_dt === null) {
            return redirect()->route('mfa.setup');
        }
        
        $secret = decrypt($mfa->totp_secret);
        
        $google2fa = new Google2FA();
        
        if (! $google2fa->verifyKey($secret, $request->input('code'))) {
            return back()
                ->withErrors([
                    'mfa' => 'Invalid verification code.',
                ]);
        }
        
        return $this->completeLogin($request);
    }

    /**
     * Password + MFA の両方を通過した時点で本ログインを成立させる。
     */
    private function completeLogin(Request $request)
    {
        $preAuth = $request->session()->get('pre_auth');
        
        // 権限レベルが変わるタイミングなので、MFA成功時にもSession IDを更新しておく。
        $request->session()->regenerate();
        
        $request->session()->put('login', [
            'id' => $preAuth['id'],
            'name' => $preAuth['name'],
        ]);
        
        $request->session()->put('permissions', $this->permissionService->getPermissions($preAuth['id']));
        $request->session()->forget('pre_auth');
        
        DB::table('log')
            ->insert([
                'employee_id' => $preAuth['id'],
                'result' => 'SUCCESS',
                'ip_address' => $request->ip(),
                'user_agent' => $request->userAgent(),
                'request_uri' => $request->getRequestUri(),
                'referer' => $request->headers->get('referer'),
                'accept_language' => $request->headers->get('accept-language'),
                'entry_dt' => now(),
            ]);
        
        return redirect()->intended('/');
    }

別件:Permission系

App/Services/PermissionService.php
namespace App\Services;

use Illuminate\Support\Facades\DB;

class PermissionService
{
    private $applied_employee_ids;
    public function __construct()
    {
        $this->applied_employee_ids = array(
            // 機能Aを使って良いID
            'Function_a' => array(
                123
                , 456
                // ......
            )
            // ......
        );
    }

    public function getPermissions($employeeId)
    {
        $employeeId = (int) $employeeId;
        
        $permissions = ['Base'];
        
        foreach($this->applied_employee_ids as $permission => $appliedEmployeeIds) {
            if (in_array($employeeId, $appliedEmployeeIds, true)) {
                $permissions[] = $permission;
            }
        }
        
        // DB判断で行う部位
        $isRegistrationDepartment = DB::table('tbl')
            ->where('employee_id', '=', $employeeId)
            ->where('department_id', '=', 555)
            ->exists();
        
        if ($isRegistrationDepartment) {
            $permissions = array_merge($permissions, [
                'Function_z',
            ]);
        }
        
        return $permissions;
    }

login セッションがある場合はログアウトボタンを配置。

template.blade.php
@if (session()->has('login'))
    <div class="top-right links">
        <span>[{{ session('login.id') }}] {{ session('login.name') }}</span>
        
        <form method="post" action="{{ route('logout') }}" style="display:inline;">
            @csrf
            <button type="submit">Logout</button>
        </form>
    </div>
@endif

ログイン画面

employee-login.blade.php
@extends('template')
@section('content')
    <div>
        <h1>My Tool</h1>
        @if ($errors->has('login'))
            <div>
                {{ $errors->first('login') }}
            </div>
        @endif
        <form id="form" method="post" action="{{route('login.post')}}">
            <h2>Login</h2>
            <div class="form-row">
                @csrf
                <label for="id">ID</label>
                <input type="text" id="id" name="id" value="{{ old('id') }}" autocomplete="username" required>
            </div>
            <div class="form-row">
                <label for="pw">Password</label>
                <input type="password" id="pw" name="pw" autocomplete="current-password" required>
            </div>
            <div class="form-row">
                <button type="submit">Login</button>
            </div>
        </form>
    </div>
@endsection

QRコード表示画面

mfa-setup.blade.php
@extends('template')
@section('content')
    <div>
        <h1>My Tool:MFA Setup</h1>  
        @if ($errors->has('mfa'))
            <div>
                {{ $errors->first('mfa') }}
            </div>
        @endif
        <div>
            <div id="qrcode" data-otp-auth-url="{{ $otpAuthUrl }}" style="width: 20.0rem;"></div>
            <p>Setup key: {{$secret}}</p>
            <form id="form" method="post" action="{{route('mfa.confirm')}}">
                <div class="form-row">
                @csrf
                    <label for="code">Code</label>
                    <input type="text" id="code" name="code" inputmode="numeric" autocomplete="one-time-code" maxlength="6" pattern="[0-9]{6}" value="" required>
                </div>
                <div class="form-row">
                    <button style="height: 2.4rem; width: 15rem;" type="submit">Confirm</button>
                </div>
            </form>
        </div>
    </div>
    <script src="{{ asset('js/qrcode.js') }}"></script>
    <script src="{{ asset('js/mfa-setup.js') }}"></script>
@endsection

コード確認画面

mfa-verify.blade.php
@extends('template')
@section('content')
    <div>
        <h1>My Tool:MFA Verification</h1>
        @if ($errors->has('mfa'))
            <div>
                {{ $errors->first('mfa') }}
            </div>
        @endif
        <p>[{{ session('pre_auth.id') }}] {{ session('pre_auth.name') }}</p>
        <form id="form" method="post" action="{{ route('mfa.verify') }}">
            @csrf
            <div class="form-row">
                <label for="code">Code</label>
                <input type="text" id="code" name="code" inputmode="numeric" autocomplete="one-time-code" maxlength="6" pattern="[0-9]{6}" required autofocus>
            </div>
            <div class="form-row">
                <button type="submit">Verify</button>
            </div>
        </form>  
    </div>  
@endsection

実運用ではMFA初回登録を許可する対象者をあらかじめ限定している。初回登録そのものを本人保証する仕組みについては別途考慮が必要。