2023/08/30

Laravel 9 Google 2FA(Two-factor authentication)

2FA全名為「Two-factor authentication」,中文稱為「雙重驗證」
兩種不同的元素,合併在一起,來確認使用者的身分。
有兩種密碼演算法
HOTP(HMAC-based One-time Password),RFC4226
金鑰和隨機資訊雜湊後,取N個字作為密碼
TOTP(Time-Based One-Time Password),RFC6238
透過時間來產生一次性密碼
邏輯如下:
呼叫API時先進到Verify2FA這個Middleware
檢查是否有使用帳號密碼登入過,沒有則讓他呼叫API
登入後如果2FA金鑰為空,回傳錯誤訊息,讓Front-End轉跳到顯示QRCode頁面
進入頁面呼叫get2FAQRCodeUrl,Front-End將URL轉成QRCode Image,接著使用者掃完後下一步
開始驗證2FA,驗證通過在Session儲存twoFAChecked為true
登入後如果還沒驗證過2FA,回傳錯誤訊息,讓Front-End轉跳到顯示輸入2FA驗證頁面
登出後將Session清空
首先安裝google2fa-laravel套件
composer require pragmarx/google2fa-laravel

Tables:
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration {
    /**
     * Run the migrations.
     * @return void
     */
    public function up()
    {
        Schema::create('users', function (Blueprint $table) {
            $table->id()->comment('使用者ID');
            $table->string('email')->nullable()->comment('電子郵件');
            $table->string('password')->comment('密碼');
            $table->integer('email_verified_at', unsigned: true)->nullable()->comment('郵件驗證時間');
            $table->text('otp_secret')->nullable()->comment('2FA');
            $table->timestamps();
        });
    }

    /**
     * Reverse the migrations.
     *
     * @return void
     */
    public function down()
    {
        Schema::dropIfExists('users');
    }
};


產生config
php artisan vendor:publish --provider="PragmaRX\Google2FALaravel\ServiceProvider"

config/google2fa.php
<?php

return [

    /*
     * Enable / disable Google2FA.
     */
    'enabled' => env('OTP_ENABLED', true),

    /*
     * Lifetime in minutes.
     *
     * In case you need your users to be asked for a new one time passwords from time to time.
     */
    'lifetime' => env('OTP_LIFETIME', 0), // 0 = eternal

    /*
     * Renew lifetime at every new request.
     */
    'keep_alive' => env('OTP_KEEP_ALIVE', true),

    /*
     * Auth container binding.
     */
    'auth' => 'auth',

    /*
     * Guard.
     */
    'guard' => '',

    /*
     * 2FA verified session var.
     */
    'session_var' => 'google2fa',

    /*
     * One Time Password request input name.
     */
    'otp_input' => 'one_time_password',

    /*
     * One Time Password Window.
     */
    'window' => 1,

    /*
     * Forbid user to reuse One Time Passwords.
     */
    'forbid_old_passwords' => false,

    /*
     * User's table column for google2fa secret.
     */
    'otp_secret_column' => 'otp_secret',

    /*
     * One Time Password View.
     */
    'view' => 'google2fa.index',

    /*
     * One Time Password error message.
     */
    'error_messages' => [
        'wrong_otp' => "The 'One Time Password' typed was wrong.",
        'cannot_be_empty' => 'One Time Password cannot be empty.',
        'unknown' => 'An unknown error has occurred. Please try again.',
    ],

    /*
     * Throw exceptions or just fire events?
     */
    'throw_exceptions' => env('OTP_THROW_EXCEPTION', true),

    /*
     * Which image backend to use for generating QR codes?
     *
     * Supports imagemagick, svg and eps
     */
    'qrcode_image_backend' => \PragmaRX\Google2FALaravel\Support\Constants::QRCODE_IMAGE_BACKEND_SVG,

];

app/Http/Kernel.php
<?php
    protected $routeMiddleware = [
    	...
        '2FA' => Verify2FA::class,
    ];

Middleware:
<?php

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;

class Verify2FA
{
    /**
     * Handle an incoming request.
     *
     * @param \Illuminate\Http\Request $request
     * @param \Closure(\Illuminate\Http\Request): (\Illuminate\Http\Response|\Illuminate\Http\RedirectResponse) $next
     * @return \Illuminate\Http\Response|\Illuminate\Http\RedirectResponse
     */
    public function handle(Request $request, Closure $next)
    {
        $lang = trim($request->header('lang'));

        //尚未登入
        if (!auth()->check()) {
            return $next($request);
        }

        //如果2FA 金鑰為空則直接回傳
        if (is_null(auth()->user()->otp_secret)) {
            return response()->json([
                'msg' => 'abc',
                'err' => 1,
            ]);
        }

        if (session('twoFAChecked', false)) {
            return response()->json([
                'msg' => 'abc',
                'err' => 2,
            ]);
        }

        return $next($request);
    }
}


<?php
namespace App\Services\v1\user;

use App\Repositories\v1\UserRepository;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use PragmaRX\Google2FA\Exceptions\IncompatibleWithGoogleAuthenticatorException;
use PragmaRX\Google2FA\Exceptions\InvalidCharactersException;
use PragmaRX\Google2FA\Exceptions\SecretKeyTooShortException;
use PragmaRX\Google2FA\Google2FA;

class UserService
{
    /**
     * Construct
     * @param Google2FA $google2fa
     * @param UserRepository $userRepo
     */
    public function __construct(protected Google2FA $google2fa, protected UserRepository $userRepo)
    {
    }

    /**
     * Login
     * @param Request $request
     * @return JsonResponse
     */
    public function login(Request $request): JsonResponse
    {
        $lang = trim($request->header('lang'));
        $username = trim($request->username);
        $password = trim($request->password);
        $user = $this->userRepo->find(['email' => $username]);
        //取得新的Token
        $token = auth()->attempt(['email' => $username, 'password' => $password]);

        $info = [
            //是否開啟OTP
            'enableOTP' => $user->enable_otp,
            'token' => $token,
        ];

        return response()->json([
            'data' => $info
        ]);
    }

    /**
     * Admin 登出
     * @param Request $request
     * @return JsonResponse
     */
    public function logout(Request $request): JsonResponse
    {
        $lang = trim($request->header('lang'));
        auth()->logout();
        //清除使用者的session
        $request->session()->flush();

        return response()->json([
            'msg' => 'test',
            'err' => 3,
        ]);
    }

    /**
     * 取得2FA QRCode URL
     * @param Request $request
     * @return JsonResponse
     * @throws IncompatibleWithGoogleAuthenticatorException
     * @throws InvalidCharactersException
     * @throws SecretKeyTooShortException
     */
    public function get2FAQRCodeUrl(Request $request): JsonResponse
    {
        $lang = trim($request->header('lang'));

        //取得當前使用者
        $currentUser = auth()->user();
        //email
        $email = $currentUser->email;
        //secret key
        $secretKey = $currentUser->otp_secret;
        //如果加密金鑰不存在則產生並更新到user
        if (empty($secretKey)) {
            $secretKey = $this->google2fa->generateSecretKey();
            $this->userRepo->modifyData($currentUser->id, ['otp_secret' => $secretKey]);
        }
        $url = $this->google2fa->getQRCodeURL('Company Name', $email, $secretKey);

        return response()->json([
            'msg' => 'test',
            'data' => [
                'url'=>$url
            ],
        ]);
    }

    /**
     * 兩部驗證
     * @param Request $request
     * @return JsonResponse
     * @throws IncompatibleWithGoogleAuthenticatorException
     * @throws InvalidCharactersException
     * @throws SecretKeyTooShortException
     */
    public function twoFAVerify(Request $request)
    {
        //OTP密碼
        $otp = trim($request->otp);
        $currentUser = auth()->user();

        //取得當前的使用者key
        $secretKey = $currentUser->otp_secret;
        $verify = $this->google2fa->verifyKey($secretKey, $otp);
        //驗證失敗
        if (!$verify) {
            return response()->json([
                'msg' => 'test',
                'err' => 4,
            ]);
        }

        //寫入twoFAChecked
        session('twoFAChecked', true);

        //第一次驗證
        if (!$currentUser->enable_otp) {
            //更新已開啟驗證
            $this->userRepo->modifyData($currentUser->id, ['enable_otp' => 1]);
        }

        return response()->json();
    }
}

Routes:
<?php
use Illuminate\Support\Facades\Route;

/*
|--------------------------------------------------------------------------
| API Routes
|--------------------------------------------------------------------------
|
| Here is where you can register API routes for your application. These
| routes are loaded by the RouteServiceProvider within a group which
| is assigned the "api" middleware group. Enjoy building your API!
|
*/

/*
Route::middleware('auth:sanctum')->get('/user', function (Request $request) {
    return $request->user();
});
*/

/**未登入*/
Route::prefix('v1')->middleware('api')->group(function () {
    Route::prefix('user')->group(function () {
        //Admin login
        Route::post('/login', [\App\Http\Controllers\v1\user\UserController::class, 'login']);
        /** Admin已登入情況下 */
        Route::middleware(['auth:user'])->group(function () {
            /**
             * 2FA
             */
            Route::prefix('2FA')->group(function () {
                //取得2FA QRCode URL
                Route::get('/get2FAQRCodeUrl', [\App\Http\Controllers\v1\user\UserController::class, 'get2FAQRCodeUrl']);
                //2FA驗證
                Route::post('/twoFAVerify', [\App\Http\Controllers\v1\user\UserController::class, 'twoFAVerify']);
            });
            /**
             * 2FA已登入情況下
             */
            Route::middleware('2FA')->group(function () {
                //登出
                Route::post('/logout', [\App\Http\Controllers\v1\user\UserController::class, 'logout']);
            });
        });
    });
});