+1

API Đăng nhập không chỉ là check Password: Chống Brute-force và quản lý Token chuẩn Enterprise

Lời mở đầu: Cánh cửa mong manh của hệ thống

Đăng nhập là cánh cửa chính (Front door) bảo vệ toàn bộ dữ liệu hệ thống của bạn. Nếu bạn chỉ viết logic kiểm tra đúng/sai, hacker có thể dùng tool tự động bắn 10.000 request/giây với hàng ngàn mật khẩu khác nhau vào API của bạn.

  1. Server Database của bạn sẽ chết ngạt vì phải thực hiện thuật toán băm (hashing) mật khẩu liên tục.
  2. Hacker có thể dò ra mật khẩu của user.

Một API Đăng nhập chuẩn Enterprise bắt buộc phải có 2 "bảo vệ":

  • Bảo vệ cổng (Rate Limiting): Gõ sai 5 lần? Khóa mõm 1 phút. Gõ sai tiếp? Khóa 1 tiếng!
  • Bảo vệ Token (Device Tracking): Cấp Token phải gắn liền với thiết bị (User-Agent/IP). Nếu cần, có thể đá văng các thiết bị cũ ra ngoài (Single Device Login).

Hãy cùng bắt tay vào refactor lại cái luồng Login "phèn chúa" nhé!

Bước 1: Request Class và Lớp khiên Rate Limiting

Chúng ta không để Controller phải lo việc đếm số lần sai. Hãy nhét nó thẳng vào FormRequest để chặn đứng hacker ngay từ vòng gửi xe.

php artisan make:request LoginRequest
// app/Http/Requests/LoginRequest.php
namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Validation\ValidationException;
use Illuminate\Support\Str;

class LoginRequest extends FormRequest
{
    public function authorize(): bool
    {
        return true;
    }

    public function rules(): array
    {
        return [
            'email' => 'required|string|email',
            'password' => 'required|string',
        ];
    }

    /**
     * Kiểm tra xem user có đang bị khóa (block) do spam không
     */
    public function ensureIsNotRateLimited(): void
    {
        if (! RateLimiter::tooManyAttempts($this->throttleKey(), 5)) {
            return;
        }

        $seconds = RateLimiter::availableIn($this->throttleKey());

        throw ValidationException::withMessages([
            'email' => trans('auth.throttle', [
                'seconds' => $seconds,
                'minutes' => ceil($seconds / 60),
            ]),
        ]);
    }

    /**
     * Tạo một ID duy nhất cho mỗi IP + Email để theo dõi spam
     */
    public function throttleKey(): string
    {
        return Str::transliterate(Str::lower($this->input('email')).'|'.$this->ip());
    }
}

Bước 2: Action Pattern xử lý cốt lõi và Token (Sanctum)

Chúng ta tiếp tục áp dụng Action Pattern để cô lập logic. Ở bước này, giả định dự án dùng Laravel Sanctum làm phương thức xác thực API (chuẩn mực hiện tại cho SPA/Mobile).

// app/Actions/LoginUserAction.php
namespace App\Actions;

use App\Models\User;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Validation\ValidationException;
use App\Http\Requests\LoginRequest;

class LoginUserAction
{
    public function execute(LoginRequest $request): array
    {
        // 1. Dựng khiên chặn spam
        $request->ensureIsNotRateLimited();

        $user = User::where('email', $request->email)->first();

        // 2. Kiểm tra mật khẩu (Xử lý gộp chung báo lỗi để tránh dò user)
        if (! $user || ! Hash::check($request->password, $user->password)) {
            // Tăng biến đếm spam lên 1
            RateLimiter::hit($request->throttleKey());

            throw ValidationException::withMessages([
                'email' => __('auth.failed'),
            ]);
        }

        // 3. Xóa biến đếm spam khi đăng nhập thành công
        RateLimiter::clear($request->throttleKey());

        // 4. [Tính năng nâng cao]: Đăng xuất các thiết bị khác (Single Device Login)
        // Nếu sếp yêu cầu 1 acc chỉ dùng trên 1 máy, hãy uncomment dòng dưới:
        // $user->tokens()->delete();

        // 5. Cấp Token và Gắn nhãn tên thiết bị (User-Agent) để dễ audit sau này
        $deviceName = $request->userAgent() ?: 'Unknown Device';
        $token = $user->createToken($deviceName)->plainTextToken;

        return [
            'user' => $user,
            'access_token' => $token,
            'token_type' => 'Bearer',
        ];
    }
}

Tại sao phải lưu $request->userAgent() vào Token? Bởi vì khi user vào trang Profile, họ có thể xem "Lịch sử đăng nhập". Họ sẽ thấy: "À, có một cái Token đang được cấp cho thiết bị iPhone 14 lúc 12h đêm qua". Nếu không phải họ, họ có thể bấm nút "Đăng xuất thiết bị này" (Tương đương lệnh xóa token đó trong bảng personal_access_tokens). Đó chính là tư duy bảo mật hệ thống lớn.

Bước 3: Controller cực kỳ tinh gọn

Như thường lệ, nhờ có Action và FormRequest, Controller của chúng ta sạch sẽ đến mức không có một khối if/else nào.

// app/Http/Controllers/Api/AuthController.php
namespace App\Http\Controllers\Api;

use App\Http\Controllers\Controller;
use App\Http\Requests\LoginRequest;
use App\Actions\LoginUserAction;

class AuthController extends Controller
{
    // ... code register hôm trước

    public function login(LoginRequest $request, LoginUserAction $action)
    {
        // Nếu validation hoặc Rate Limit fail, nó đã văng Exception từ trước rồi.
        
        $result = $action->execute($request);

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

Đăng ký route:

Route::post('/login', [AuthController::class, 'login']);

Bước 4: Thử lửa với Postman - Xem "Khiên chắn" hoạt động

Chúng ta sẽ test để thấy cái API này xịn xò đến mức nào.

Kịch bản 1: Đăng nhập thành công (Happy Path)

  • Method: POST
  • URL: [http://127.0.0.1:8000/api/login](http://127.0.0.1:8000/api/login)
  • Headers:
  • Accept: application/json
  • User-Agent: PostmanRuntime/7.32.3 (Thường postman sẽ tự gắn cái này)
  • Body (raw - JSON):
{
    "email": "kysudom@example.com",
    "password": "Password123!"
}

Kết quả (Expected - 200 OK):

{
    "success": true,
    "message": "Đăng nhập thành công",
    "data": {
        "user": {
            "id": 1,
            "name": "Kỹ sư dởm",
            "email": "kysudom@example.com"
        },
        "access_token": "1|LaravelSanctumTokenSinhRaOday...",
        "token_type": "Bearer"
    }
}

(Nếu bạn mở Database, bảng personal_access_tokens sẽ lưu trữ tên token là PostmanRuntime/7.32.3. Rất chuẩn chỉ cho việc audit!)

Kịch bản 2: Hacker đóng giả và tấn công Brute-force Bây giờ, bạn hãy cố tình nhập sai mật khẩu 6 lần liên tiếp. Hãy bấm nút Send trên Postman liên tục như một con Bot.

Lần thứ 1 đến lần thứ 5, API trả về lỗi 422:

{
    "message": "Tài khoản hoặc mật khẩu không chính xác.",
    "errors": {
        "email": ["Tài khoản hoặc mật khẩu không chính xác."]
    }
}

Nhưng đến lần Bấm thứ 6! Hệ thống lập tức tung khiên chắn! API sẽ trả về mã lỗi HTTP 429 Too Many Requests:

{
    "message": "Quá nhiều lần thử đăng nhập. Vui lòng thử lại sau 59 giây.",
    "errors": {
        "email": [
            "Quá nhiều lần thử đăng nhập. Vui lòng thử lại sau 59 giây."
        ]
    }
}

Từ giây phút này, dù hacker có vô tình mò đúng mật khẩu đi chăng nữa, hệ thống cũng chặn đứng không cho chạy xuống tầng Database để check. Server của bạn hoàn toàn an toàn và không bị ngốn CPU!

Tóm lại

API Đăng nhập là chốt chặn sinh tử.

  1. Rate Limiting: Chống Brute-force, bảo vệ CPU của Database.
  2. Sanctum Device Name: Lưu vết thiết bị, cho phép User có quyền quản lý phiên đăng nhập (Session/Token Management).
  3. Action Pattern: Code mạch lạc, tái sử dụng cao.

Anh em đừng bao giờ để cái API Login "chuồng gà" lên môi trường Production nhé. Hãy refactor ngay hôm nay!


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í