client端透過呼叫 POST /login/create_token,帶上帳號與 RSA 加密後的密碼登入系統,這支 API 原本沒有次數限制,攻擊者可以對同一個帳號無限次猜密碼,所以要幫這支API加入限流機制避免被攻擊。
限流規則包含:
最直覺的做法,是限制同一個 IP 或同一個帳號能嘗試登入幾次,但兩種做法都有缺點:
前兩種做法的問題是:只限制 IP,攻擊者可以換 IP;只限制帳號,又可能害帳號本人無法登入。 因此,需要同時考慮 IP 和帳號,並且避免在時間窗切換時突然恢復全部額度。
| 層 | 計數對象 | 預設上限 | 對付 | 額度何時恢復 |
|---|---|---|---|---|
| L1 | 來源 IP | 5 次/分 | 同一個 IP 大量嘗試登入 | 每次取得的許可經過 1 分鐘後釋放 |
| L2 | 帳號 + 來源 IP | 每 15 分鐘 5 次 | 從同一個 IP 猜同一個帳號 | 登入成功時刪除這組帳號與 IP 的限流紀錄 |
| L3 | 帳號(只記不受信任的 IP) | 20 次/小時 | 從不同 IP 分散猜同一個帳號 | 每次取得的許可經過 1 小時後釋放 |
三層依序判斷,判斷集中在 LoginRateLimiter:
private boolean acquireAll(String account, String ip) {
if (!acquire(key(IP_PREFIX, properties.getIp(), ip), properties.getIp())) {
return reject("IP", account, ip);
}
if (!acquire(userIpKey(account, ip), properties.getUserIp())) {
return reject("USER_IP", account, ip);
}
if (trustedIps(account).contains(ip)) {
return true;
}
if (!acquire(key(USER_PREFIX, properties.getUser(), account), properties.getUser())) {
return reject("USER", account, ip);
}
return true;
}
每次登入都先檢查 L1,再檢查 L2;通過後,如果來源 IP 是受信任的,就直接跳過 L3,否則才檢查 L3。前一層擋下時,後面的層就不再扣額度。例如,L3 額度用完時,這次請求仍然消耗了 L1、L2 的額度(只有成功取得許可的那一層才會扣額度)。
每一層都是 Redisson 的 RRateLimiter,採用滑動時間窗——每次取得的許可,要滿一個時間窗才會釋放,不會在整點一次全部歸零,因此不會因為時間窗交界而瞬間放進兩倍流量。
L3 會把不同 IP 對同一帳號的嘗試累積起來,假使攻擊者從 20 個 IP 各猜一次,Alice 就可能有一小時無法登入,重新落入「別人可以鎖住我的帳號」的問題。針對這個問題,在登入成功時,系統會把該 IP 記為這個帳號的受信任 IP,保留 30 天:
public void onSuccess(String userId, String ip) {
String account = normalize(userId);
try {
redissonClient.getRateLimiter(userIpKey(account, ip)).delete();
trustedIps(account).add(ip, properties.getTrustedIpTtl().toMillis(), TimeUnit.MILLISECONDS);
} catch (RuntimeException e) {
log.warn("登入成功後更新限流紀錄失敗(Redis 故障?) ip={}", ip, e);
}
}
只有登入成功才會將來源 IP 加入受信任清單,因此攻擊者在不知道密碼的情況下,無法將自己的 IP 設為受信任。登入成功的判定也必須在 Refresh Token 寫入完成後才成立;如果寫入失敗,這次登入就不能被視為成功,也不能清除帳號與 IP 的限流紀錄或將 IP 加入受信任清單。
受信任 IP 只能跳過 L3,仍須通過 L1、L2 的限制。此外,登入成功時不會清除 L3 的計數,避免帳號本人每成功登入一次,就替攻擊者清除累積的嘗試次數。
直覺上,可以等密碼驗證失敗後再增加計數,但這種做法有個問題:「檢查失敗次數與增加計數是兩個步驟,並發請求可能同時通過檢查。」
舉例:
目前已經累積 4 次失敗,上限是 5 次。兩個請求同時抵達時,都可能讀到「目前只有 4 次失敗」,因此一起進入密碼驗證。即使上限是 5 次,也無法阻止這兩個請求繼續執行。大量請求同時進來時,就可能讓許多請求一起消耗 BCrypt 的運算資源。
因此,這個專案改成在解密密碼之前,先向tryAttempt(tryAttempt 是限流元件 LoginRateLimiter 對外的入口)取得許可:
public LoginResponse login(LoginRequest request, String encryptedPassword, String clientIp) {
if (!loginRateLimiter.tryAttempt(request.getUserId(), clientIp)) {
throw new AuthenticationException("帳密錯誤");
}
String rawPassword;
try {
rawPassword = passwordUtils.decryptPassword(encryptedPassword);
} catch (Exception e) {
throw new AuthenticationException("帳密錯誤");
}
...
tokenRedisService.saveRefreshToken(refreshTokenId, user.getUserId(), ttlSeconds);
loginRateLimiter.onSuccess(request.getUserId(), clientIp);
return new LoginResponse(accessToken, refreshTokenId, accessTokenExpiresIn);
}
tryAttempt 在驗證密碼之前檢查並取得許可。額度用完時,請求直接被拒絕,不必解密密碼或執行 BCrypt。剩餘額度存在 Redis,檢查與扣減在 Redis 上一次完成:額度只剩 1 次時,即使兩個請求同時進來,也只有一個能取得許可,另一個會被拒絕。
這樣做的代價是,系統計算的是登入嘗試次數,而不是單純的密碼錯誤次數。因為系統在驗證密碼前就先扣除額度,所以不論密碼正確或錯誤,只要請求取得了限流許可,就會先計入登入次數。
三層限流能限制短時間內的大量登入嘗試,但仍有幾個邊界:
在做完限流功能後,發現其實不管怎麼做都很難面面俱到,還是直接改用 Passkeys 會更快?~(誤