+3

Series Laravel & Google 2FA Thực chiến #4: "Phao cứu sinh" Recovery Codes – Giải cứu khi mất điện thoại

1. Recovery Codes là gì và hoạt động ra sao?

Recovery Codes là một tập hợp các chuỗi ký tự ngẫu nhiên (thường là 8-10 mã, ví dụ: d8f4-a9c2-7b1e).

  • Đặc tính 1: Nó được cấp phát duy nhất một lần ngay sau khi cài đặt 2FA xong. User phải tự in ra giấy hoặc lưu vào nơi an toàn.
  • Đặc tính 2: Mỗi mã chỉ được dùng một lần duy nhất. Dùng xong hệ thống sẽ xóa nó đi (Burn after reading).
  • Đặc tính 3: Nó có quyền lực tương đương với mã OTP 6 số. Khi đăng nhập, nếu không có OTP, user có thể nhập 1 trong các mã phục hồi này để lọt qua "Phòng chờ".

2. Chuẩn bị Database: Kho chứa phao

Chúng ta cần một chỗ để lưu danh sách các mã này. Vì mỗi mã chỉ được dùng một lần, chúng ta sẽ lưu nó dưới dạng mảng JSON trong bảng users.

Lưu ý bảo mật tối cao: Chúng ta KHÔNG bao giờ lưu mã thô (raw text) xuống Database. Phải băm (Hash) nó ra giống như mật khẩu, đề phòng Hacker lấy được DB.

Tạo file migration:

php artisan make:migration add_recovery_codes_to_users_table

Nội dung file migration:

public function up(): void
{
    Schema::table('users', function (Blueprint $table) {
        // Cột lưu mảng JSON các mã đã bị hash
        $table->text('two_factor_recovery_codes')->nullable()->after('google2fa_secret');
    });
}

Chạy php artisan migrate và thêm 'two_factor_recovery_codes' vào biến $hidden trong file Model User.php để tránh bị lộ qua API.

3. Sinh mã ngay khi kích hoạt 2FA thành công

Hãy quay lại file TwoFactorSetupController.php ở Bài 2. Trong hàm confirm, ngay sau đoạn lưu google2fa_secret thành công, chúng ta sẽ chèn logic sinh ra 8 mã phục hồi.

use Illuminate\Support\Str;
use Illuminate\Support\Facades\Hash;

    // ... (logic cũ của hàm confirm) ...
    if ($isValid) {
        $user->google2fa_secret = encrypt($secretKey); 

        // SINH 8 MÃ PHỤC HỒI NGẪU NHIÊN
        $recoveryCodes = [];
        $hashedCodes = [];

        for ($i = 0; $i < 8; $i++) {
            // Sinh chuỗi ngẫu nhiên, ví dụ: 43a2b1-9f8e7d
            $code = Str::random(6) . '-' . Str::random(6);
            
            // Giữ lại bản rõ để trả về cho User nhìn thấy
            $recoveryCodes[] = $code; 
            
            // Băm ra để lưu xuống DB (Bảo mật 100%)
            $hashedCodes[] = Hash::make($code); 
        }

        $user->two_factor_recovery_codes = json_encode($hashedCodes);
        $user->save();

        Cache::forget($cacheKey);

        return response()->json([
            'success' => true,
            'message' => 'Kích hoạt 2FA thành công. HÃY LƯU LẠI CÁC MÃ PHỤC HỒI NÀY!',
            // Trả về bản rõ CHỈ 1 LẦN DUY NHẤT này
            'recovery_codes' => $recoveryCodes 
        ]);
    }

Giao diện (Frontend) khi nhận được mảng recovery_codes này phải hiện một cảnh báo đỏ chót yêu cầu User copy và lưu lại.

4. Nâng cấp "Phòng chờ": Chấp nhận Mật mã cứu sinh

Cuối cùng, chúng ta sửa lại hàm verify2FA ở Bài 3. Bây giờ, user truyền lên biến otp_code. Nếu nó là 6 số, ta cho chạy thuật toán TOTP. Nếu nó dài hơn (có chứa dấu gạch ngang), ta cho nó đối chiếu với kho Recovery Codes.

public function verify2FA(Request $request)
    {
        // ... (Logic tìm user từ cache như bài 3) ...
        
        $code = $request->otp_code;
        $isValid = false;

        // KỊCH BẢN 1: Người dùng nhập mã 6 số từ App
        if (preg_match('/^\d{6}$/', $code)) {
            $secretKey = decrypt($user->google2fa_secret);
            $isValid = Google2FA::verifyKey($secretKey, $code);
        } 
        
        // KỊCH BẢN 2: Người dùng nhập mã phục hồi (Ví dụ: 43a2b1-9f8e7d)
        else {
            $hashedCodes = json_decode($user->two_factor_recovery_codes, true) ?? [];
            
            // Quét qua mảng mã phục hồi xem có khớp cái nào không
            foreach ($hashedCodes as $index => $hashedCode) {
                if (Hash::check($code, $hashedCode)) {
                    $isValid = true;
                    
                    // XÓA MÃ ĐÃ DÙNG (Burn after reading)
                    unset($hashedCodes[$index]);
                    $user->two_factor_recovery_codes = json_encode(array_values($hashedCodes));
                    $user->save();
                    
                    break;
                }
            }
        }

        // Nếu qua được 1 trong 2 kịch bản trên
        if ($isValid) {
            Cache::forget($cacheKey);
            $token = $user->createToken('AFC-Metro-Token')->plainTextToken;

            return response()->json([
                'success' => true,
                'access_token' => $token,
                'message' => 'Đăng nhập thành công!'
            ]);
        }

        return response()->json(['message' => 'Mã không chính xác.'], 401);
    }

Tổng kết Bài 4

Thật hoàn hảo! Bạn vừa thiết kế xong một cơ chế dự phòng sự cố đúng tiêu chuẩn khắt khe nhất của các tập đoàn công nghệ lớn:

  1. Dễ dùng: Tích hợp chung vào cùng một ô nhập liệu (User nhập OTP cũng được, nhập Recovery Code cũng xong).
  2. An toàn tuyệt đối: Mã phục hồi được băm (hash) cẩn thận trước khi lưu xuống Database. Kẻ xấu có chọc được vào DB thì mớ mã đó cũng vô giá trị.
  3. Dùng một lần: Logic tự tiêu hủy (Burn after reading) đảm bảo một mã nếu vô tình bị lộ sau khi xài cũng không thể xài lại lần 2.

Hệ thống bảo mật 2FA của bạn giờ đây vừa kiên cố như một pháo đài, lại vừa đủ linh hoạt để không tự nhốt chính những nhân sự hợp pháp của mình ở ngoài cửa. Việc xử lý thông minh những tiểu tiết này chính là ranh giới phân biệt giữa một Junior và một Senior Backend Engineer!


All rights reserved

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí