Nếu như Xác thực Email bằng Signed URL là bài toán về sự thanh lịch và tối ưu Database, thì Xác thực Số điện thoại (OTP qua SMS) lại là bài toán của Tiền bạc và Chống phá hoại (Anti-Fraud).
Làm việc với các hệ thống giao dịch tự động cường độ cao, chắc hẳn anh em rất thấm thía việc kiểm soát các luồng request liên tục. Lỗi logic ở API gửi SMS không chỉ gây sập server mà còn "đốt" sạch ngân sách của công ty chỉ trong một đêm. Một tin nhắn SMS tốn khoảng 500đ - 800đ. Nếu không có cơ chế phòng thủ, một hacker rảnh rỗi có thể viết tool cào (bot) gọi API gửi OTP 10.000 lần/giờ. Bùm! Sáng hôm sau sếp gọi bạn lên phòng kế toán giải trình hóa đơn SMS vài chục triệu.
Hôm nay, trên Viblo, mình sẽ lên bài "trấn yểm" API Xác thực SĐT bằng combo Redis (In-memory Cache) và Double Rate-Limiting (Giới hạn kép) chuẩn Enterprise nhé!
Lời mở đầu: Tội ác của việc lưu OTP vào Database Relational (MySQL/PostgreSQL)
Giống như việc xác thực Email, phản xạ đầu tiên của nhiều dev khi làm tính năng gửi mã OTP (One Time Password) là:
- Thêm cột
otp_codevàotp_expires_atvào bảngusers(hoặc tạo hẳn bảnguser_otps). - Sinh mã random 6 số, lưu xuống DB.
- Gửi SMS.
- User nhập mã -> Query DB kiểm tra -> Đúng thì xóa mã.
Cách làm này chứa 3 tử huyệt:
- Chậm và Rác DB: OTP là thứ dữ liệu "phù du" (chỉ sống 2-3 phút). Việc bắt ổ cứng (Disk) của Database phải liên tục Ghi (Insert) rồi lại Xóa (Delete) hàng ngàn cái OTP mỗi phút là một sự lãng phí tài nguyên khủng khiếp.
- Không chống được Spam SMS: User cứ bấm "Gửi lại mã" liên tục, hệ thống cứ thế gửi, cạn kiệt ngân sách tích hợp API của nhà mạng (Twilio, eSMS...).
- Dễ bị Brute-force: Mã OTP thường chỉ có 6 số (1 triệu trường hợp). Nếu bạn không giới hạn số lần NHẬP SAI, hacker có thể dùng tool thử từ
000000đến999999để qua mặt hệ thống.
Đã đến lúc đập bỏ cách làm cũ, mang kiến trúc In-memory (Redis) vào cuộc chơi.
Bước 1: Redis - Chân ái của dữ liệu "Phù du"
Thay vì dùng Database, chúng ta sẽ lưu OTP vào Cache (Redis). Tại sao? Vì Redis lưu trên RAM (cực nhanh), và nó có cơ chế TTL (Time To Live). Bạn set cho cái OTP sống 3 phút, đúng 3 phút sau Redis tự động "hủy thi diệt tích", bạn không cần viết thêm bất kỳ dòng code dọn rác nào.
Đầu tiên, hãy tạo 2 Action Class để xử lý riêng biệt: Gửi OTP và Xác nhận OTP.
Bước 2: Action Gửi OTP - Khiên chắn Spam SMS
Chúng ta áp dụng Rate Limiter ngay tại tầng Action để đảm bảo: Một số điện thoại chỉ được nhận 1 SMS mỗi phút.
// app/Actions/SendOtpAction.php
namespace App\Actions;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Validation\ValidationException;
use App\Models\User;
class SendOtpAction
{
public function execute(User $user, string $phone): void
{
// 1. Khóa Rate Limit (1 phút / 1 số điện thoại)
$throttleKey = 'send_otp_' . $phone;
if (RateLimiter::tooManyAttempts($throttleKey, 1)) {
$seconds = RateLimiter::availableIn($throttleKey);
throw ValidationException::withMessages([
'phone' => "Vui lòng đợi {$seconds} giây trước khi gửi lại OTP."
]);
}
// 2. Sinh mã OTP ngẫu nhiên 6 số
$otpCode = (string) random_int(100000, 999999);
// 3. Lưu vào Redis, sống đúng 3 phút (180 giây)
Cache::put('otp_' . $phone, $otpCode, now()->addMinutes(3));
// Cài đặt bộ đếm số lần nhập sai (Khởi tạo = 0)
Cache::put('otp_attempts_' . $phone, 0, now()->addMinutes(3));
// 4. Kích hoạt gửi SMS (Ở đây gọi API nhà mạng, mình dùng Log để giả lập)
// SmsService::send($phone, "Ma OTP cua ban la: {$otpCode}");
\Log::info("Đã gửi OTP {$otpCode} đến SĐT {$phone}");
// 5. Ghi nhận đã gửi 1 lần vào Rate Limiter (khóa 60 giây)
RateLimiter::hit($throttleKey, 60);
}
}
Bước 3: Action Xác thực OTP - Chặn đứng Brute-force
Khi user nhập OTP gửi lên, ta phải kiểm tra không chỉ việc mã đúng/sai, mà còn phải đếm số lần nhập sai. Nếu sai quá 3 lần, hủy luôn cái OTP đó để chống hacker dùng tool dò mã.
// app/Actions/VerifyOtpAction.php
namespace App\Actions;
use Illuminate\Support\Facades\Cache;
use Illuminate\Validation\ValidationException;
use App\Models\User;
class VerifyOtpAction
{
public function execute(User $user, string $phone, string $otpInput): bool
{
$cacheKeyOtp = 'otp_' . $phone;
$cacheKeyAttempts = 'otp_attempts_' . $phone;
// 1. Kiểm tra OTP có tồn tại không (chưa gửi hoặc đã hết hạn 3 phút)
if (!Cache::has($cacheKeyOtp)) {
throw ValidationException::withMessages(['otp' => 'Mã OTP không tồn tại hoặc đã hết hạn.']);
}
// 2. Kiểm tra số lần nhập sai (Tối đa 3 lần)
$attempts = Cache::get($cacheKeyAttempts, 0);
if ($attempts >= 3) {
// Hacker dò mã! Hủy luôn OTP hiện tại để bảo vệ
Cache::forget($cacheKeyOtp);
Cache::forget($cacheKeyAttempts);
throw ValidationException::withMessages(['otp' => 'Bạn đã nhập sai quá nhiều lần. Vui lòng gửi lại mã mới.']);
}
// 3. Đối chiếu mã OTP
$validOtp = Cache::get($cacheKeyOtp);
if ($otpInput !== $validOtp) {
// Tăng số lần nhập sai lên 1
Cache::increment($cacheKeyAttempts);
throw ValidationException::withMessages(['otp' => 'Mã OTP không chính xác.']);
}
// 4. OTP chính xác -> Cập nhật Database, dọn dẹp Cache
$user->update([
'phone' => $phone,
'phone_verified_at' => now()
]);
Cache::forget($cacheKeyOtp);
Cache::forget($cacheKeyAttempts);
return true;
}
}
Bước 4: Controller làm nhiệm vụ điều hướng
Giờ ta chỉ việc nối Action vào Controller. Bạn nhớ làm thêm FormRequest để validate string, regex số điện thoại nhé (mình bỏ qua để code gọn lại).
// app/Http/Controllers/Api/PhoneVerificationController.php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Actions\SendOtpAction;
use App\Actions\VerifyOtpAction;
use Illuminate\Http\Request;
class PhoneVerificationController extends Controller
{
// Yêu cầu route này phải đi qua middleware auth:sanctum
public function sendOtp(Request $request, SendOtpAction $action)
{
$request->validate(['phone' => 'required|regex:/^([0-9\s\-\+\(\)]*)$/|min:10']);
$action->execute($request->user(), $request->phone);
return response()->json([
'success' => true,
'message' => 'Mã OTP đã được gửi. Vui lòng kiểm tra tin nhắn.'
]);
}
public function verifyOtp(Request $request, VerifyOtpAction $action)
{
$request->validate([
'phone' => 'required|string',
'otp' => 'required|digits:6' // Bắt buộc 6 số
]);
$action->execute($request->user(), $request->phone, $request->otp);
return response()->json([
'success' => true,
'message' => 'Xác thực số điện thoại thành công!'
]);
}
}
Bước 5: Thử lửa Postman - Khi Hacker khóc thét
Chúng ta không test "Happy Path" (nhập đúng là xong), hãy thử xem hệ thống chống chịu thế nào với các kịch bản phá hoại.
Kịch bản 1: Spam SMS (Bot cố tình gọi API gửi tin nhắn liên tục)
- User/Hacker gọi API
POST /api/phone/send-otplần 1 -> Server báo 200 OK. - Chưa đầy 60 giây sau, Hacker tiếp tục gọi lại API này để cào SMS của bạn.
- Kết quả: Rate Limiter ném thẳng Exception 422 vào mặt Hacker, bảo vệ tiền cho công ty:
{
"message": "Vui lòng đợi 55 giây trước khi gửi lại OTP.",
"errors": {
"phone": ["Vui lòng đợi 55 giây trước khi gửi lại OTP."]
}
}
Kịch bản 2: Tấn công dò mã (Brute-force)
Hacker đã lấy được API POST /api/phone/verify-otp. Hắn viết script chạy thử từ 000000 đến 999999.
- Lần 1 thử mã
111111-> Lỗi: "Mã OTP không chính xác." - Lần 2 thử mã
222222-> Lỗi: "Mã OTP không chính xác." - Lần 3 thử mã
333333-> Lỗi: "Mã OTP không chính xác." - Lần 4 thử mã
444444-> KẾT LIỄU! Server nhận thấy số lần thử (Attempts) > 3. Nó tự động XÓA luôn cục OTP thực sự trong Redis. Trả về mã lỗi:
{
"message": "Bạn đã nhập sai quá nhiều lần. Vui lòng gửi lại mã mới.",
"errors": {
"otp": ["Bạn đã nhập sai quá nhiều lần. Vui lòng gửi lại mã mới."]
}
}
Từ giờ phút này, mọi Request dò pass tiếp theo đều dính lỗi "Mã OTP không tồn tại hoặc đã hết hạn". Hacker chính thức bó tay!
Tóm lại
Làm việc với các API có tính phí (như SMS), bảo mật và quản lý tài nguyên phải được đặt lên hàng đầu.
- Dùng Redis thay vì DB: Tránh làm phình Database, tự động hóa vòng đời của dữ liệu nhờ TTL.
- Double Rate Limiting: Chặn đầu Gửi SMS (tiết kiệm tiền) và Chặn đầu Xác thực (chống dò mã).
Đây là "nội công tâm pháp" không thể thiếu của các backend dev khi làm việc ở các tập đoàn lớn. Chúc anh em đem vào dự án thành công và có giấc ngủ ngon!