0

Xác thực Email: Đừng lưu Token vào Database nữa, hãy dùng Signed URL!

Nhiều anh em code xong luồng Register (Đăng ký) rồi thì làm phần Xác thực Email (Verify Email) một cách rất ngây ngô: Sinh ra một chuỗi random token -> Lưu vào cột verify_token trong Database -> Gửi link có chứa token đó qua email -> User click vào link thì query DB tìm token rồi update email_verified_at.

Cách này chạy được, nhưng nó lại lòi ra một rổ Technical Debt (Nợ kỹ thuật):

  1. DB của bạn phình to vì phải chứa một đống token rác của những user không bao giờ click xác thực.
  2. Bạn phải viết thêm Job chạy ngầm để xóa các token hết hạn.
  3. Rất dễ dính lỗi bảo mật nếu token sinh ra không đủ độ ngẫu nhiên.

Hôm nay, mình sẽ hướng dẫn anh em đập bỏ cái cột verify_token đó đi, và áp dụng Stateless Verification (Xác thực không lưu trạng thái) bằng Signed URL (URL có chữ ký điện tử) chuẩn Enterprise.

Lời mở đầu: Căn bệnh "Lưu mọi thứ vào Database"

Khi nhận task làm tính năng "Xác thực tài khoản Email" sau khi người dùng đăng ký, 90% anh em dev mới sẽ làm theo luồng này:

  1. Thêm 1 cột verification_token vào bảng users.
  2. Lúc user đăng ký, random một chuỗi 1a2b3c... lưu vào cột đó.
  3. Gửi email chứa link: [domain.com/verify?token=1a2b3c](https://domain.com/verify?token=1a2b3c)... . Thấy đúng thì cho qua.

Luồng này có gì sai? Nó biến Database của bạn thành một bãi rác. Bạn phải tốn dung lượng lưu token, tốn công viết logic kiểm tra token hết hạn (expired_at), và tốn cả một câu query để tìm kiếm (search) trên một cột chứa chuỗi ngẫu nhiên (rất chậm nếu không đánh index).

Trong kiến trúc hệ thống hiện đại, chúng ta giải quyết bài toán này bằng sự thanh lịch của mật mã học: Signed URL (Đường dẫn có chữ ký điện tử).

1. Signed URL hoạt động như thế nào?

Thay vì sinh ra token và lưu vào DB, Server sẽ lấy ID của user, kết hợp với thời gian hết hạn (Expiration Time), và dùng "Khóa bí mật" của Server (chính là biến APP_KEY trong file .env) để băm (Hash) ra một chữ ký (Signature).

Đường link gửi qua email sẽ trông như thế này:

[domain.com/api/verify/123?expires=1680000000&signature=a8f5c3b9e](https://domain.com/api/verify/123?expires=1680000000&signature=a8f5c3b9e)...

Khi user click vào link, Server KHÔNG CẦN chọc xuống Database. Nó chỉ việc lấy ID và Expiration Time trên URL, dùng lại APP_KEY băm thử một lần nữa.

  • Nếu kết quả băm trùng với cái signature trên URL -> Link chuẩn, do chính Server sinh ra, chưa bị ai sửa đổi.
  • Nếu hacker cố tình đổi ID từ 123 thành 124 -> Chữ ký sẽ bị sai lệch hoàn toàn -> Server đuổi cổ ngay lập tức.

Tuyệt vời chưa? Hoàn toàn Stateless (Không lưu trạng thái), không tốn 1 byte nào trong Database!

2. Thực chiến Code: Sinh ra Signed URL

Quay lại với bài toán "Đăng ký tài khoản" hôm trước. Trong Listener SendWelcomeEmail, thay vì chỉ gửi thư chào mừng, ta sẽ tạo một URL xác thực có chữ ký, hạn dùng trong 60 phút.

// app/Listeners/SendWelcomeEmail.php
namespace App\Listeners;

use App\Events\UserRegistered;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Support\Facades\URL;
use Illuminate\Support\Facades\Mail;

class SendWelcomeEmail implements ShouldQueue
{
    public function handle(UserRegistered $event): void
    {
        $user = $event->user;

        // Tạo Signed URL cho route 'verification.verify'
        // Hạn dùng: 60 phút tính từ hiện tại
        $verifyUrl = URL::temporarySignedRoute(
            'verification.verify', // Tên route
            now()->addMinutes(60), // Thời gian hết hạn
            [
                'id' => $user->id,
                'hash' => sha1($user->email), // Thêm email hash để tăng bảo mật, tránh việc user đổi email giữa chừng
            ]
        );

        // Gửi email kèm cái link $verifyUrl này
        Mail::to($user->email)->send(new WelcomeEmail($user, $verifyUrl));
    }
}

3. API xử lý xác thực (Verify Endpoint)

Giờ ta cần tạo cái Route và Controller để đón lấy cái Request khi user bấm vào link trong Email.

// routes/api.php
use App\Http\Controllers\Api\VerificationController;

Route::get('/verify/{id}/{hash}', [VerificationController::class, 'verify'])
    ->name('verification.verify');

Nhìn cái Controller của chúng ta đi, cực kỳ tinh gọn và bảo mật:

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

use App\Http\Controllers\Controller;
use App\Models\User;
use Illuminate\Http\Request;

class VerificationController extends Controller
{
    public function verify(Request $request, $id, $hash)
    {
        // 1. Kiểm tra URL có hợp lệ, đúng chữ ký và chưa hết hạn không?
        if (! $request->hasValidSignature()) {
            return response()->json([
                'success' => false,
                'message' => 'Đường dẫn xác thực không hợp lệ hoặc đã hết hạn.'
            ], 403);
        }

        // 2. Tìm User
        $user = User::findOrFail($id);

        // 3. Kiểm tra xem hash email trên URL có khớp với email thật trong DB không
        if (! hash_equals((string) $hash, sha1($user->email))) {
            return response()->json([
                'success' => false,
                'message' => 'Dữ liệu không khớp.'
            ], 403);
        }

        // 4. Kiểm tra xem user đã xác thực trước đó chưa (Idempotency)
        if ($user->hasVerifiedEmail()) {
            return response()->json([
                'success' => true,
                'message' => 'Tài khoản đã được xác thực từ trước.'
            ]);
        }

        // 5. Đánh dấu đã xác thực
        $user->markEmailAsVerified();

        return response()->json([
            'success' => true,
            'message' => 'Xác thực tài khoản thành công!'
        ]);
    }
}

4. Thử lửa kịch bản phá hoại với Postman

Để thấy sự lợi hại của Signed URL, chúng ta không test đường "Happy Path" (bấm đúng link là xong), mà hãy đóng vai Hacker phá hoại.

Giả sử link chuẩn gửi vào email là:

[http://127.0.0.1:8000/api/verify/1/a8f5c...b9e?
expires=1680000000&signature=99xyz]
(http://127.0.0.1:8000/api/verify/1/a8f5c...b9e?
expires=1680000000&signature=99xyz)...

Kịch bản 1: Hacker sửa ID của user khác Hacker muốn xác thực hộ tài khoản của sếp (ID = 2). Hắn copy link của hắn, đổi số 1 thành số 2 trên URL rồi gửi Request trên Postman.

  • Kết quả:

Server nhặt nguyên cục URL đó ném vào hàm băm cùng với APP_KEY. Hàm băm phát hiện nội dung đầu vào đã bị sửa. Chữ ký (signature) sinh ra không khớp với chữ ký 99xyz... ban đầu.

  • Response lập tức văng:
{
    "success": false,
    "message": "Đường dẫn xác thực không hợp lệ hoặc đã hết hạn."
}

Kịch bản 2: Vượt quá thời gian 60 phút URL hoàn toàn nguyên vẹn, không bị sửa 1 ký tự nào. Nhưng tham số expires đã trôi qua so với giờ hiện tại của Server.

  • Kết quả: Hàm hasValidSignature() kiểm tra thấy expires < now() -> Từ chối thẳng thừng, không cần chạm tới Database.

Kịch bản 3: User bấm xác thực 2 lần (Double click) Lần 1 bấm, API trả về "Xác thực thành công". Vài ngày sau rảnh rỗi bấm vào link cũ trong email lần nữa (lúc này giả sử URL vẫn chưa hết hạn).

  • Kết quả: Block số 4 trong code hoạt động. Mọi thứ vẫn êm đềm nhả ra 200 OK:
{
    "success": true,
    "message": "Tài khoản đã được xác thực từ trước."
}

(Đây gọi là tính Idempotency - Tính lũy đẳng trong thiết kế API: Gọi 1 lần hay 100 lần thì trạng thái hệ thống vẫn không bị hỏng).

Tóm lại

Chỉ bằng việc tận dụng hasValidSignature() của Laravel, chúng ta đã:

  • Giải phóng Database khỏi việc lưu trữ token rác.
  • Ngăn chặn hoàn toàn rủi ro bị sửa đổi thông tin xác thực.
  • Thiết kế API đạt chuẩn Idempotency.

Kiến trúc tốt là kiến trúc biết cách vận dụng sức mạnh của mật mã học thay vì lạm dụng ổ cứng Database. Anh em hãy check lại source code dự án mình và dọn dẹp cái cột verify_token ngay nhé!


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í