Series Laravel & Google 2FA Thực chiến #3: Cải tổ API Login và "Phòng chờ" 2FA Challenge
1. Vấn đề của API Login truyền thống
Trong một hệ thống không có 2FA, luồng đăng nhập rất đơn giản: User gửi Email/Pass ➔ Đúng ➔ Trực tiếp nhả ra Access Token (JWT/Sanctum).
Khi có 2FA, chúng ta không thể nhả Access Token ngay được. Khách hàng (hoặc Frontend) phải vượt qua một trạm kiểm tra nữa. Giải pháp chuẩn Enterprise cho API là Token Exchange (Trao đổi mã) kết hợp với Cache.
Luồng mới sẽ như sau:
- Gửi Email/Pass.
- Đúng Pass ➔ Hệ thống kiểm tra xem User này có bật 2FA không.
- Nếu CÓ ➔ Không trả Token thật. Trả về một challenge_id (vé chờ) và đuổi ra "Phòng chờ".
- Ở "Phòng chờ", User gửi cái challenge_id đó cùng mã OTP 6 số lên.
- Đúng OTP ➔ Hệ thống thu hồi vé chờ và phát Access Token thật!
2. Sửa đổi Controller Đăng nhập (Login)
Hãy mở file xử lý Login hiện tại của bạn ra (ví dụ: AuthController.php) và độ lại hàm login:
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use App\Models\User;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Str;
class AuthController extends Controller
{
public function login(Request $request)
{
$request->validate([
'email' => 'required|email',
'password' => 'required'
]);
$user = User::where('email', $request->email)->first();
// 1. Kiểm tra Mật khẩu cơ bản
if (!$user || !Hash::check($request->password, $user->password)) {
return response()->json(['message' => 'Sai thông tin đăng nhập.'], 401);
}
// 2. Chốt chặn 2FA: Kiểm tra xem User có bật khiên thép chưa?
if (!empty($user->google2fa_secret)) {
// Sinh ra một mã vé chờ ngẫu nhiên
$challengeId = (string) Str::uuid();
// Lưu ID của user vào Cache với vé chờ này, hiệu lực trong 5 phút
// Nếu 5 phút sau không nhập OTP, vé chờ tự hủy, phải login lại.
Cache::put('2fa_challenge_' . $challengeId, $user->id, now()->addMinutes(5));
return response()->json([
'success' => true,
'requires_2fa' => true,
'challenge_id' => $challengeId,
'message' => 'Vui lòng nhập mã 6 số từ Google Authenticator.'
]);
}
// 3. Nếu chưa bật 2FA, cấp Access Token luôn (như bình thường)
$token = $user->createToken('AFC-Metro-Token')->plainTextToken;
return response()->json([
'success' => true,
'requires_2fa' => false,
'access_token' => $token
]);
}
}
Nhờ đoạn code này, Hacker có ăn trộm được mật khẩu thật cũng chỉ nhận về một thông báo bắt nhập mã OTP, và chúng sẽ hoàn toàn tắc tịt ở bước này.
3. Viết API giải quyết "Phòng chờ" (Verify Challenge)
Bước tiếp theo, chúng ta cần một API để Frontend gọi lên khi user điền xong mã 6 số. API này sẽ kiểm tra vé chờ và mã OTP. Cùng viết hàm verify2FA vào trong AuthController:
use PragmaRX\Google2FALaravel\Facade as Google2FA;
public function verify2FA(Request $request)
{
$request->validate([
'challenge_id' => 'required|string',
'otp_code' => 'required|digits:6'
]);
// 1. Tìm user thông qua vé chờ trong Cache
$cacheKey = '2fa_challenge_' . $request->challenge_id;
$userId = Cache::get($cacheKey);
if (!$userId) {
return response()->json([
'success' => false,
'message' => 'Phiên đăng nhập đã hết hạn. Vui lòng đăng nhập lại.'
], 400);
}
$user = User::find($userId);
// 2. Lấy Secret Key trong DB ra (nhớ giải mã vì Bài 2 ta đã encrypt nó)
$secretKey = decrypt($user->google2fa_secret);
// 3. Thuật toán TOTP kiểm tra tính hợp lệ
$isValid = Google2FA::verifyKey($secretKey, $request->otp_code);
if ($isValid) {
// Hợp lệ! Xóa vé chờ đi để không ai dùng lại được
Cache::forget($cacheKey);
// CẤP PHÁT QUYỀN LỰC TỐI CAO: Trả về Access Token thật
$token = $user->createToken('AFC-Metro-Token')->plainTextToken;
return response()->json([
'success' => true,
'access_token' => $token,
'message' => 'Đăng nhập thành công!'
]);
}
// Sai mã OTP
return response()->json([
'success' => false,
'message' => 'Mã OTP không chính xác.'
], 401);
}
4. Đăng ký Route cho hệ thống
Cuối cùng, mở file routes/api.php ra và gắn 2 API này vào cổng thông tin không cần Auth (vì lúc này User chưa có Access Token).
use App\Http\Controllers\Api\AuthController;
// Luồng Login mới
Route::post('/login', [AuthController::class, 'login']);
Route::post('/login/verify-2fa', [AuthController::class, 'verify2FA']);
Tổng kết Bài 3
Chỉ với kỹ thuật sử dụng Cache làm "Phòng chờ", chúng ta đã giải quyết thành công bài toán 2FA cho kiến trúc API phi trạng thái (Stateless). Frontend bây giờ đã có một luồng rất rõ ràng: Nhận requires_2fa: true thì mở popup bắt nhập mã, rồi lấy challenge_id kèm mã đẩy lên API thứ 2 để lấy Token.
Hệ thống AFC giờ đây đã được khóa chặt thêm một lớp khóa không thể phá vỡ bằng các phương pháp dò mật khẩu thông thường.
Với việc cấp quyền 2FA này, bạn có thể đối mặt với một vấn đề thực tế đau đầu không kém: Nếu một nhân viên bị mất điện thoại, họ sẽ bị khóa vĩnh viễn khỏi hệ thống. Ở Bài 4, bạn muốn chúng ta thiết kế tính năng sinh Recovery Codes (Mã phục hồi 1 lần) để dự phòng, hay muốn tìm hiểu cách xây dựng giao diện Admin cho phép Trưởng ga Reset (Xóa) 2FA của nhân viên cấp dưới?
All rights reserved