iT邦幫忙

2026 iThome 鐵人賽

DAY 27
0
Vibe Coding

做一個團購後端,順便搞懂那些事系列 第 27 篇

Day 27|登入 API 被暴力猜密碼,該限制誰?

  • 分享至 

  • xImage
  •  

client端透過呼叫 POST /login/create_token,帶上帳號與 RSA 加密後的密碼登入系統,這支 API 原本沒有次數限制,攻擊者可以對同一個帳號無限次猜密碼,所以要幫這支API加入限流機制避免被攻擊。

限流規則包含:

  1. 攻擊者不能無限次猜密碼。
  2. 別人在猜的時候,帳號本人仍然要能登入。

問題一:限制登入次數,為什麼還是不夠?

最直覺的做法,是限制同一個 IP 或同一個帳號能嘗試登入幾次,但兩種做法都有缺點:

  1. 同一個 IP 每分鐘最多登入 5 次:攻擊者換 IP 就能繼續猜密碼,而且共用同一個 IP 的使用者也可能一起被擋。
  2. 同一個帳號每 15 分鐘最多失敗 5 次:攻擊者故意輸錯 5 次,就能讓帳號本人暫時無法登入。
  3. 每到整點就重設次數:攻擊者可以在 0:59 嘗試 5 次,1:00 再嘗試 5 次,短短兩秒就送出 10 次請求。

前兩種做法的問題是:只限制 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 次時,即使兩個請求同時進來,也只有一個能取得許可,另一個會被拒絕。

這樣做的代價是,系統計算的是登入嘗試次數,而不是單純的密碼錯誤次數。因為系統在驗證密碼前就先扣除額度,所以不論密碼正確或錯誤,只要請求取得了限流許可,就會先計入登入次數。

目前設計的限制

三層限流能限制短時間內的大量登入嘗試,但仍有幾個邊界:

  1. 撞庫攻擊:如果攻擊者拿大量外洩帳密,每組只嘗試一次,三層限流都不一定會觸發,還需要 MFA 或異常登入偵測。
  2. 共用出口 IP:如果使用者共用IP,等同 L1 額度,同時登入時可能被誤擋。
  3. 陌生 IP 登入:如果攻擊者從大量 IP 猜同一個帳號,帳號本人從未使用過的 IP 也可能因 L3 額度用完而暫時無法登入。
  4. Redis 故障:目前選擇Redis 故障時選擇放行(fail-open)並記錄警告,再繼續驗證密碼,避免限流服務故障時所有人都無法登入;當然,這麼做的代價就是故障期間會失去限流保護。

在做完限流功能後,發現其實不管怎麼做都很難面面俱到,還是直接改用 Passkeys 會更快?~(誤


上一篇
Day 26|通知會漏、會亂序,訊息裡該放什麼?
下一篇
Day 28|使用者重送「下單」,同一份餐點會被扣兩次款嗎?
系列文
做一個團購後端,順便搞懂那些事 共 28 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言