LoginAttemptService.java

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
Location : checkAllowed
Killed by : com.rbctcsworld.ecommerce.auth.LoginAttemptServiceTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.auth.LoginAttemptServiceTest]/[method:fifthFailureLocksForFifteenMinutesWithRetryAfter()]
removed conditional - replaced comparison check with false → KILLED

2.2
Location : checkAllowed
Killed by : com.rbctcsworld.ecommerce.auth.LoginAttemptServiceTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.auth.LoginAttemptServiceTest]/[method:otherAccountsAreNotAffected()]
changed conditional boundary → KILLED

44

1.1
Location : checkAllowed
Killed by : com.rbctcsworld.ecommerce.auth.LoginAttemptServiceTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.auth.LoginAttemptServiceTest]/[method:fifthFailureLocksForFifteenMinutesWithRetryAfter()]
Replaced long division with multiplication → KILLED

2.2
Location : checkAllowed
Killed by : com.rbctcsworld.ecommerce.auth.LoginAttemptServiceTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.auth.LoginAttemptServiceTest]/[method:fifthFailureLocksForFifteenMinutesWithRetryAfter()]
Replaced long addition with subtraction → KILLED

51

1.1
Location : loginFailed
Killed by : com.rbctcsworld.ecommerce.auth.LoginAttemptServiceTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.auth.LoginAttemptServiceTest]/[method:failuresSpreadOverMoreThanTheWindowDoNotLock()]
removed call to com/rbctcsworld/ecommerce/auth/LoginAttemptStore::recordFailure → KILLED

55

1.1
Location : loginSucceeded
Killed by : com.rbctcsworld.ecommerce.auth.LoginAttemptServiceTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.auth.LoginAttemptServiceTest]/[method:successResetsTheCounter()]
removed call to com/rbctcsworld/ecommerce/auth/LoginAttemptStore::reset → KILLED

59

1.1
Location : failures
Killed by : com.rbctcsworld.ecommerce.auth.LoginAttemptServiceTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.auth.LoginAttemptServiceTest]/[method:failuresSpreadOverMoreThanTheWindowDoNotLock()]
replaced int return with 0 for com/rbctcsworld/ecommerce/auth/LoginAttemptService::failures → KILLED

Active mutators

Tests examined


Report generated by PIT 1.30.0 support