OTP email/SMS, TTL, chống brute force, gửi lạiEmail/SMS OTP, TTL, brute force, resend
OTP Service kiểm tra giới hạn, sinh mã, lưu bản HMAC vào Redis rồi đẩy việc gửi sang hàng notify.critical. Sender chọn kênh SMS hoặc email. Bật “Nhập sai 5 lần” để xem khoá, “Gửi lại quá sớm” để xem cooldown và giới hạn theo số điện thoại.The OTP service checks the limits, generates the code, stores its HMAC in Redis and hands delivery to the notify.critical queue. The sender picks SMS or email. Turn on “5 wrong attempts” to see the lock, and “Resend too early” to see the cooldown and the per-phone limit.
1Người dùng nhập số điện thoại để đăng nhập.The user enters a phone number to sign in.
Mã 6 chữ số chỉ có 10^6 khả năng. Tối đa 5 lần thử cho mỗi mã và khoá 15 phút khi hết lượt thì xác suất đoán trúng là 5/10^6 cho mỗi mã. Tăng bộ đếm bằng HINCRBY trước khi so để request song song không vượt được giới hạn.A 6-digit code has only 10^6 values. Five attempts per code and a 15-minute lock afterwards give a 5-in-a-million chance per code. Increment the counter with HINCRBY before comparing so parallel requests cannot exceed the limit.
Chỉ lưu HMAC-SHA256(server_key, key ‖ code). Băm SHA-256 thường không đủ: 10^6 giá trị thì dò lại trong vài mili giây. Che mã trong log và trong payload gửi qua hàng đợi nếu có thể.Store only HMAC-SHA256(server_key, key ‖ code). Plain SHA-256 is not enough: 10^6 values can be reversed in milliseconds. Mask the code in logs, and in the queue payload where possible.
Dùng SecureRandom hoặc crypto/rand, không dùng Random, Math.random hay math/rand vì có thể suy ra trạng thái từ vài mã liên tiếp. Định dạng %06d để giữ số 0 ở đầu. Bước sinh mã nằm trong template dùng chung nên không mục đích nào thay được bằng bộ sinh yếu hơn.Use SecureRandom or crypto/rand, never Random, Math.random or math/rand, whose state can be inferred from a few consecutive codes. Format with %06d to keep leading zeros. The generate step lives in the shared template, so no purpose can swap in a weaker generator.
Kẻ gian gọi API gửi OTP tới hàng loạt số trả phí để ăn chia cước. Giới hạn theo số điện thoại, theo IP và theo tiền tố quốc gia, cooldown 60 giây, CAPTCHA khi lưu lượng bất thường, và chặn các đầu số không phục vụ.Fraudsters call the OTP API for ranges of premium numbers to share the termination fees. Limit per phone, per IP and per country prefix, keep a 60-second cooldown, add a CAPTCHA on unusual traffic and block prefixes you do not serve.
equals() dừng ở byte khác đầu tiên. Dùng MessageDigest.isEqual hoặc subtle.ConstantTimeCompare để thời gian so không phụ thuộc vào số byte trùng. Phép so nằm ở bước verify của template dùng chung, nên kênh mới thêm vào không phải tự viết lại.equals() stops at the first differing byte. Use MessageDigest.isEqual or subtle.ConstantTimeCompare so the comparison time does not depend on how many bytes match. The comparison sits in the verify step of the shared template, so a new channel never rewrites it.
Strategy chọn SMS, email hoặc Zalo ZNS; khi một nhà cung cấp lỗi thì chuyển sang nhà cung cấp dự phòng. Template Method cố định các bước kiểm giới hạn → sinh mã → lưu → gửi → audit, mỗi mục đích (LOGIN, SIGNUP, CHANGE_PHONE) chỉ đổi TTL và nội dung tin.A Strategy picks SMS, email or Zalo ZNS and fails over to a backup provider. A Template Method fixes the steps check limits → generate → store → send → audit; each purpose (LOGIN, SIGNUP, CHANGE_PHONE) only changes the TTL and message text.
Xoá khoá ngay khi khớp và chỉ chấp nhận nếu lệnh DEL trả về 1, để hai request cùng mã chỉ một bên thành công. Gắn mã với purpose để mã đăng nhập không dùng được cho đổi số điện thoại. Gói các thao tác này sau một OtpStore chỉ có save() và consume(), để không nơi nào đọc rồi xoá rời rạc.Delete the key as soon as it matches and accept only if DEL returns 1, so of two requests with the same code only one succeeds. Bind the code to its purpose so a sign-in code cannot be used to change the phone number. Hide these operations behind an OtpStore with only save() and consume(), so nothing reads and deletes separately.
| BảngTable | Cột chínhKey columns | Ghi chúNotes |
|---|---|---|
Redis otp:{purpose}:{target} | HASH h = HMAC(code), n = attempts · EXPIRE 300 | Xoá khi xác minh đúng hoặc khi n đạt 5Deleted on a correct match or when n reaches 5 |
Redis otp:cd:{target} · otp:lock:{target} | SET NX EX 60 · SET EX 900 | Cooldown gửi lại và khoá sau 5 lần saiResend cooldown and the lock after 5 wrong attempts |
Redis rl:otp:phone:{phone} · rl:otp:ip:{ip} | INCR + EXPIRE (fixed window 15 phút / 1 giờ) | 3 lần gửi / 15 phút mỗi số, 20 lần / giờ mỗi IP3 sends per 15 minutes per number, 20 per hour per IP |
otp_audit | id, target_hash, purpose, event, channel, provider, ip, created_at | event = SENT · VERIFIED · FAILED · LOCKED; lưu hash của số điện thoạievent = SENT · VERIFIED · FAILED · LOCKED; stores a hash of the phone number |
totp_secret | user_id, secret_encrypted, algorithm, digits, period, confirmed_at, last_used_step | Secret mã hoá bằng KMS; last_used_step chặn dùng lại cùng một mã TOTPSecret encrypted with KMS; last_used_step stops the same TOTP code being reused |
Phát hành và xác minh OTP trên Redis. Giới hạn theo số điện thoại và IP chạy ở một filter phía trước; ở đây là cooldown, bộ đếm lượt thử, so sánh thời gian hằng và dùng một lần.Issuing and verifying an OTP on Redis. The per-phone and per-IP limits run in a filter in front; this code covers the cooldown, the attempt counter, the constant-time comparison and single use.
@Service
@RequiredArgsConstructor
public class OtpService {
private static final Duration TTL = Duration.ofMinutes(5);
private static final Duration COOLDOWN = Duration.ofSeconds(60);
private static final int MAX_ATTEMPTS = 5;
private final StringRedisTemplate redis;
private final OtpHasher hasher; // HMAC-SHA256, server-side key
private final OtpSender sender; // Strategy: SMS or email
private final SecureRandom random = new SecureRandom();
public void issue(String target, Purpose purpose) {
if (Boolean.TRUE.equals(redis.hasKey("otp:lock:" + target))) throw new OtpLockedException();
Boolean first = redis.opsForValue().setIfAbsent("otp:cd:" + target, "1", COOLDOWN);
if (!Boolean.TRUE.equals(first)) {
throw new TooEarlyException(redis.getExpire("otp:cd:" + target));
}
String otp = "%06d".formatted(random.nextInt(1_000_000));
String key = "otp:" + purpose + ":" + target;
redis.opsForHash().putAll(key, Map.of("h", hasher.hash(key, otp), "n", "0"));
redis.expire(key, TTL);
sender.send(target, purpose, otp); // enqueues on notify.critical
}
public boolean verify(String target, Purpose purpose, String input) {
if (Boolean.TRUE.equals(redis.hasKey("otp:lock:" + target))) throw new OtpLockedException();
String key = "otp:" + purpose + ":" + target;
Object stored = redis.opsForHash().get(key, "h");
if (stored == null) return false; // expired, used or never sent
long n = redis.opsForHash().increment(key, "n", 1);
if (n > MAX_ATTEMPTS) return false; // a parallel request already used the last try
boolean match = MessageDigest.isEqual(
stored.toString().getBytes(UTF_8),
hasher.hash(key, input).getBytes(UTF_8));
if (match) return Boolean.TRUE.equals(redis.delete(key));
if (n >= MAX_ATTEMPTS) {
redis.delete(key);
redis.opsForValue().set("otp:lock:" + target, "1", Duration.ofMinutes(15));
throw new OtpLockedException();
}
return false;
}
}const (
otpTTL = 5 * time.Minute
cooldown = 60 * time.Second
maxAttempts = 5
)
type OtpService struct {
rdb *redis.Client
hasher Hasher // HMAC-SHA256, server-side key
sender Sender // Strategy: SMS or email
}
func (s *OtpService) Issue(ctx context.Context, target string, p Purpose) error {
ok, err := s.rdb.SetNX(ctx, "otp:cd:"+target, 1, cooldown).Result()
if err != nil {
return err
}
if !ok {
left, _ := s.rdb.TTL(ctx, "otp:cd:"+target).Result()
return &TooEarlyError{RetryAfter: left}
}
n, err := rand.Int(rand.Reader, big.NewInt(1_000_000)) // crypto/rand
if err != nil {
return err
}
code := fmt.Sprintf("%06d", n.Int64())
key := fmt.Sprintf("otp:%s:%s", p, target)
pipe := s.rdb.TxPipeline() // MULTI … EXEC
pipe.HSet(ctx, key, "h", s.hasher.Hash(key, code), "n", 0)
pipe.Expire(ctx, key, otpTTL)
if _, err := pipe.Exec(ctx); err != nil {
return err
}
return s.sender.Send(ctx, target, p, code)
}
func (s *OtpService) Verify(ctx context.Context, target string, p Purpose, input string) (bool, error) {
key := fmt.Sprintf("otp:%s:%s", p, target)
stored, err := s.rdb.HGet(ctx, key, "h").Result()
if errors.Is(err, redis.Nil) {
return false, nil // expired, used or never sent
} else if err != nil {
return false, err
}
n := s.rdb.HIncrBy(ctx, key, "n", 1).Val()
if n > maxAttempts {
return false, ErrOtpLocked
}
if subtle.ConstantTimeCompare([]byte(stored), []byte(s.hasher.Hash(key, input))) == 1 {
deleted, err := s.rdb.Del(ctx, key).Result()
return deleted == 1, err
}
if n >= maxAttempts {
s.rdb.Del(ctx, key)
s.rdb.Set(ctx, "otp:lock:"+target, 1, 15*time.Minute)
return false, ErrOtpLocked
}
return false, nil
}| OTP qua SMS / emailOTP by SMS / email | TOTP (Google Authenticator, 1Password)TOTP (Google Authenticator, 1Password) | |
|---|---|---|
| Cách hoạt độngHow it works | Server sinh mã, lưu bản băm, gửi qua nhà cung cấpThe server generates a code, stores its hash and sends it through a provider | HMAC-SHA1(secret, ⌊giờ / 30s⌋), ứng dụng và server tự tính, cho lệch ±1 bướcHMAC-SHA1(secret, ⌊time / 30s⌋) computed by the app and the server, allowing ±1 step of drift |
| Chi phíCost | Trả tiền mỗi tin, có nguy cơ SMS pumpingPay per message, exposed to SMS pumping | Không tốn phí gửi, không cần mạng di độngNo delivery cost, works without a mobile network |
| Bảo mậtSecurity | Dễ bị SIM swap và chặn tinExposed to SIM swap and message interception | Secret nằm trên thiết bị; vẫn bị phishing thời gian thựcThe secret stays on the device; still open to real-time phishing |
| Chọn khiPick it when | Xác minh số điện thoại, người dùng phổ thông, lần đầu đăng kýVerifying a phone number, mainstream users, first sign-up | MFA cho tài khoản quản trị, người dùng chấp nhận cài ứng dụngMFA for admin accounts, users willing to install an app |
Mỗi pattern mở bài học tương ứng trong menu Pattern.Each one opens its lesson in the Patterns menu.
Vì nó sống ngắn (5 phút), chỉ cho 5 lần thử rồi khoá, và chỉ dùng một lần. Xác suất đoán trúng là 5/10^6 mỗi mã. Nếu không giới hạn lượt thử thì 6 chữ số dò hết trong vài phút.Because it lives briefly (5 minutes), allows 5 attempts before a lock, and works once. The chance of a hit is 5 in a million per code. Without an attempt limit, 6 digits fall in minutes.
SHA-256 của 10^6 giá trị bị dò ngược ngay lập tức. bcrypt chậm nhưng không cứu được không gian nhỏ như vậy và tốn CPU ở 300 rps. HMAC với khoá bí mật phía server khiến bản băm vô dụng nếu chỉ lộ Redis.SHA-256 over 10^6 values is reversed instantly. bcrypt is slow but cannot save such a small space and costs CPU at 300 rps. An HMAC with a server-side secret makes the stored value useless if only Redis leaks.
HINCRBY nguyên tử chạy trước khi so. Chỉ 5 request đầu nhận được n từ 1 đến 5 và được so; các request sau thấy n > 5 hoặc khoá đã bị xoá. Nếu đọc n, so, rồi mới ghi n+1 thì cả 50 request đều được thử.An atomic HINCRBY runs before the comparison. Only the first 5 requests get n from 1 to 5 and are compared; the rest see n > 5 or a deleted key. Reading n, comparing, then writing n+1 would let all 50 try.
TOTP (RFC 6238) tính HMAC trên secret chung và bước thời gian 30 giây, nên không cần gửi tin. Server cho lệch ±1 bước và lưu bước đã dùng cuối cùng để chặn dùng lại. Secret được trao một lần qua mã QR và phải mã hoá khi lưu.TOTP (RFC 6238) computes an HMAC over a shared secret and a 30-second time step, so nothing is sent. The server allows ±1 step of drift and records the last used step to block replay. The secret is shared once through a QR code and must be encrypted at rest.
Phím 1–4 để chọn đáp án, Enter để kiểm tra.Press 1–4 to choose an answer, Enter to check.
Ôn tập ngắt quãng (spaced repetition): thẻ được lặp lại sau 1 → 3 → 7 → 14 → 30 ngày để củng cố trí nhớ dài hạn.Spaced repetition: cards are repeated after 1 → 3 → 7 → 14 → 30 days to build long-term memory.