| 1 | package com.rbctcsworld.ecommerce.auth; | |
| 2 | ||
| 3 | import com.rbctcsworld.ecommerce.common.exception.TooManyRequestsException; | |
| 4 | import org.springframework.beans.factory.annotation.Autowired; | |
| 5 | import org.springframework.stereotype.Service; | |
| 6 | ||
| 7 | import java.time.Clock; | |
| 8 | import java.time.Duration; | |
| 9 | ||
| 10 | /** | |
| 11 | * Brute-force protection for the login endpoint. | |
| 12 | * | |
| 13 | * - After MAX failed logins for one e-mail within the WINDOW, that e-mail is locked for LOCK time: | |
| 14 | * every login (even with the right password) gets 429 + Retry-After, so guessing stops. | |
| 15 | * - Unknown e-mails are counted exactly like real ones, so the 429 does not reveal which accounts exist. | |
| 16 | * - A successful login clears the counter. | |
| 17 | * - The counting lives in a LoginAttemptStore: Redis when app.security.login.store=redis (shared by all | |
| 18 | * backend instances, survives restarts), otherwise in memory. See LoginAttemptStoreConfig. | |
| 19 | * | |
| 20 | * Trade-off (documented): someone who knows a customer's e-mail can lock that account for 15 minutes. | |
| 21 | * A temporary lock (not a permanent one) keeps that risk small. | |
| 22 | */ | |
| 23 | @Service | |
| 24 | public class LoginAttemptService { | |
| 25 | ||
| 26 | private final LoginAttemptStore store; | |
| 27 | ||
| 28 | @Autowired | |
| 29 | public LoginAttemptService(LoginAttemptStore store) { | |
| 30 | this.store = store; | |
| 31 | } | |
| 32 | ||
| 33 | /** In-memory variant for unit tests. */ | |
| 34 | LoginAttemptService(Clock clock, int maxFailures, long windowMinutes, long lockMinutes) { | |
| 35 | this(new InMemoryLoginAttemptStore(clock, maxFailures, Duration.ofMinutes(windowMinutes), | |
| 36 | Duration.ofMinutes(lockMinutes))); | |
| 37 | } | |
| 38 | ||
| 39 | /** Throws 429 while the e-mail is locked. Call BEFORE checking the password. */ | |
| 40 | public void checkAllowed(String email) { | |
| 41 | long millis = store.lockRemainingMillis(email); | |
| 42 |
2
1. checkAllowed : removed conditional - replaced comparison check with false → KILLED 2. checkAllowed : changed conditional boundary → KILLED |
if (millis > 0) { |
| 43 | // round UP: a client that waits exactly Retry-After seconds must not still be locked | |
| 44 |
2
1. checkAllowed : Replaced long division with multiplication → KILLED 2. checkAllowed : Replaced long addition with subtraction → KILLED |
long seconds = Math.max(1, (millis + 999) / 1000); |
| 45 | throw new TooManyRequestsException( | |
| 46 | "Too many failed login attempts. Try again in " + seconds + " seconds.", seconds); | |
| 47 | } | |
| 48 | } | |
| 49 | ||
| 50 | public void loginFailed(String email) { | |
| 51 |
1
1. loginFailed : removed call to com/rbctcsworld/ecommerce/auth/LoginAttemptStore::recordFailure → KILLED |
store.recordFailure(email); |
| 52 | } | |
| 53 | ||
| 54 | public void loginSucceeded(String email) { | |
| 55 |
1
1. loginSucceeded : removed call to com/rbctcsworld/ecommerce/auth/LoginAttemptStore::reset → KILLED |
store.reset(email); |
| 56 | } | |
| 57 | ||
| 58 | int failures(String email) { | |
| 59 |
1
1. failures : replaced int return with 0 for com/rbctcsworld/ecommerce/auth/LoginAttemptService::failures → KILLED |
return store.failures(email); |
| 60 | } | |
| 61 | } | |
Mutations | ||
| 42 |
1.1 2.2 |
|
| 44 |
1.1 2.2 |
|
| 51 |
1.1 |
|
| 55 |
1.1 |
|
| 59 |
1.1 |