AuthService.java

1
package com.rbctcsworld.ecommerce.auth;
2
3
import com.rbctcsworld.ecommerce.common.exception.ConflictException;
4
import com.rbctcsworld.ecommerce.common.exception.ForbiddenException;
5
import com.rbctcsworld.ecommerce.common.exception.InvalidCredentialsException;
6
import com.rbctcsworld.ecommerce.common.exception.InvalidRefreshTokenException;
7
import org.springframework.security.crypto.password.PasswordEncoder;
8
import org.springframework.stereotype.Service;
9
10
@Service
11
public class AuthService {
12
13
    public static final String DISABLED = "This account is disabled. Please contact the store.";
14
15
    private final UserRepository repo;
16
    private final PasswordEncoder encoder;
17
    private final JwtService jwt;
18
    private final LoginAttemptService attempts;
19
    private final RefreshTokenService refreshTokens;
20
    private final TwoFactorService twoFactor;
21
    private volatile String dummyHash;
22
23
    public AuthService(UserRepository repo, PasswordEncoder encoder, JwtService jwt, LoginAttemptService attempts,
24
                       RefreshTokenService refreshTokens, TwoFactorService twoFactor) {
25
        this.repo = repo;
26
        this.encoder = encoder;
27
        this.jwt = jwt;
28
        this.attempts = attempts;
29
        this.refreshTokens = refreshTokens;
30
        this.twoFactor = twoFactor;
31
    }
32
33
    /** New accounts are always CUSTOMER; admins are only created by DataSeeder. */
34
    public AuthResult register(String rawEmail, String password) {
35
        String email = normalize(rawEmail);
36 1 1. register : removed conditional - replaced equality check with false → KILLED
        if (repo.findByEmail(email).isPresent()) {
37
            throw new ConflictException("Email already registered");
38
        }
39
        AppUser user = repo.save(new AppUser(email, encoder.encode(password)));
40 1 1. register : replaced return value with null for com/rbctcsworld/ecommerce/auth/AuthService::register → KILLED
        return result(user, refreshTokens.issue(user));
41
    }
42
43
    public static final String GUEST_AGAIN = "You already checked out as a guest with this e-mail. "
44
            + "Use \"Forgot password\" to set a password and see your orders.";
45
    public static final String HAS_ACCOUNT = "This e-mail has an account. Please log in.";
46
47
    /**
48
     * Guest checkout: a customer account with a random password nobody knows. The guest can shop, pay and follow
49
     * the order in this browser (the session works like any other); "Forgot password" turns it into a full
50
     * account. An e-mail that already has an account - full or guest - is refused (409), so nobody can open
51
     * somebody else's orders by typing their e-mail.
52
     */
53
    public AuthResult guest(String rawEmail) {
54
        String email = normalize(rawEmail);
55
        AppUser existing = repo.findByEmail(email).orElse(null);
56 1 1. guest : removed conditional - replaced equality check with false → KILLED
        if (existing != null) {
57 1 1. guest : removed conditional - replaced equality check with false → KILLED
            throw new ConflictException(existing.isGuest() ? GUEST_AGAIN : HAS_ACCOUNT);
58
        }
59
        byte[] secret = new byte[32];
60 1 1. guest : removed call to java/security/SecureRandom::nextBytes → KILLED
        RANDOM.nextBytes(secret);
61
        AppUser user = repo.save(AppUser.guest(email, encoder.encode(java.util.Base64.getEncoder().encodeToString(secret))));
62 1 1. guest : replaced return value with null for com/rbctcsworld/ecommerce/auth/AuthService::guest → KILLED
        return result(user, refreshTokens.issue(user));
63
    }
64
65
    private static final java.security.SecureRandom RANDOM = new java.security.SecureRandom();
66
67
    /**
68
     * Attackers must not be able to discover which e-mails have accounts:
69
     *  - same 401 message for an unknown e-mail and a wrong password;
70
     *  - same TIME: for an unknown e-mail a bcrypt check still runs (against a dummy hash). Without it
71
     *    an unknown e-mail answered in ~1 ms and a real one in ~90 ms - measurable from outside.
72
     * Brute force: after 5 failures the e-mail is locked for 15 minutes (429), see LoginAttemptService.
73
     */
74
    public AuthResult login(String rawEmail, String password) {
75
        String email = normalize(rawEmail);
76 1 1. login : removed call to com/rbctcsworld/ecommerce/auth/LoginAttemptService::checkAllowed → KILLED
        attempts.checkAllowed(email);
77
        AppUser user = repo.findByEmail(email).orElse(null);
78
        boolean ok;
79 1 1. login : removed conditional - replaced equality check with false → KILLED
        if (user != null) {
80
            ok = encoder.matches(password, user.getPassword());
81
        } else {
82
            encoder.matches(password, dummyHash());   // result ignored: only spends the same time as a real check
83
            ok = false;
84
        }
85 1 1. login : removed conditional - replaced equality check with false → KILLED
        if (!ok) {
86 1 1. login : removed call to com/rbctcsworld/ecommerce/auth/LoginAttemptService::loginFailed → KILLED
            attempts.loginFailed(email);
87
            throw new InvalidCredentialsException();
88
        }
89 1 1. login : removed call to com/rbctcsworld/ecommerce/auth/LoginAttemptService::loginSucceeded → KILLED
        attempts.loginSucceeded(email);
90 1 1. login : removed conditional - replaced equality check with false → KILLED
        if (!user.isActive()) {
91
            // only told after the right password, so it does not reveal which e-mails have accounts
92
            throw new ForbiddenException(DISABLED);
93
        }
94 1 1. login : removed conditional - replaced equality check with false → SURVIVED
        if (user.isTwoFactorEnabled()) {
95
            // right password, but the account also wants the e-mailed code: no tokens yet (REQ-AUTH-16)
96
            throw new TwoFactorRequiredException(twoFactor.begin(user));
97
        }
98 1 1. login : replaced return value with null for com/rbctcsworld/ecommerce/auth/AuthService::login → KILLED
        return result(user, refreshTokens.issue(user));
99
    }
100
101
    /** Second step of a two-factor sign-in: the e-mailed code finishes it. */
102
    public AuthResult verifyLogin(String challengeToken, String code) {
103
        AppUser user = twoFactor.verify(challengeToken, code);
104 1 1. verifyLogin : removed conditional - replaced equality check with false → NO_COVERAGE
        if (!user.isActive()) {
105
            throw new ForbiddenException(DISABLED);
106
        }
107 1 1. verifyLogin : removed call to com/rbctcsworld/ecommerce/auth/LoginAttemptService::loginSucceeded → NO_COVERAGE
        attempts.loginSucceeded(user.getEmail());
108 1 1. verifyLogin : replaced return value with null for com/rbctcsworld/ecommerce/auth/AuthService::verifyLogin → NO_COVERAGE
        return result(user, refreshTokens.issue(user));
109
    }
110
111
    /** New access token + the next refresh token (the used one is spent). 401 for anything not valid. */
112
    public AuthResult refresh(String refreshToken) {
113
        RefreshTokenService.Rotated rotated = refreshTokens.rotate(refreshToken);
114 1 1. refresh : removed conditional - replaced equality check with false → KILLED
        if (!rotated.user().isActive()) {
115
            throw new InvalidRefreshTokenException();
116
        }
117 1 1. refresh : replaced return value with null for com/rbctcsworld/ecommerce/auth/AuthService::refresh → KILLED
        return result(rotated.user(), rotated.refreshToken());
118
    }
119
120
    /** Ends the session on the server: the refresh token (and its whole family) stops working. */
121
    public void logout(String refreshToken) {
122 1 1. logout : removed call to com/rbctcsworld/ecommerce/auth/RefreshTokenService::revoke → KILLED
        refreshTokens.revoke(refreshToken);
123
    }
124
125
    private AuthResult result(AppUser user, String refreshToken) {
126 1 1. result : replaced return value with null for com/rbctcsworld/ecommerce/auth/AuthService::result → KILLED
        return new AuthResult(jwt.generate(user.getEmail()), user.getEmail(), user.getRole(), refreshToken,
127
                jwt.expiresInSeconds(), user.isGuest());
128
    }
129
130
    private String dummyHash() {
131 1 1. dummyHash : removed conditional - replaced equality check with false → KILLED
        if (dummyHash == null) {
132
            dummyHash = encoder.encode("timing-equalizer-" + System.nanoTime());
133
        }
134 1 1. dummyHash : replaced return value with "" for com/rbctcsworld/ecommerce/auth/AuthService::dummyHash → KILLED
        return dummyHash;
135
    }
136
137
    private static String normalize(String email) {
138 2 1. normalize : replaced return value with "" for com/rbctcsworld/ecommerce/auth/AuthService::normalize → KILLED
2. normalize : removed conditional - replaced equality check with false → KILLED
        return email == null ? null : email.trim().toLowerCase();
139
    }
140
141
    /**
142
     * token = access token (JWT, short); refreshToken = opaque, long; expiresIn = access token lifetime in seconds;
143
     * guest = a guest checkout account (no password of its own yet).
144
     */
145
    public record AuthResult(String token, String email, String role, String refreshToken, long expiresIn, boolean guest) {
146
    }
147
}

Mutations

36

1.1
Location : register
Killed by : com.rbctcsworld.ecommerce.auth.AuthServiceTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.auth.AuthServiceTest]/[method:registerDuplicateEmailIsConflict()]
removed conditional - replaced equality check with false → KILLED

40

1.1
Location : register
Killed by : com.rbctcsworld.ecommerce.auth.AuthServiceTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.auth.AuthServiceTest]/[method:registerNormalizesEmailAndCreatesCustomer()]
replaced return value with null for com/rbctcsworld/ecommerce/auth/AuthService::register → KILLED

56

1.1
Location : guest
Killed by : com.rbctcsworld.ecommerce.auth.GuestCheckoutTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.auth.GuestCheckoutTest]/[method:aSecondGuestCheckoutWithTheSameEmailIsToldToSetAPassword()]
removed conditional - replaced equality check with false → KILLED

57

1.1
Location : guest
Killed by : com.rbctcsworld.ecommerce.auth.GuestCheckoutTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.auth.GuestCheckoutTest]/[method:aSecondGuestCheckoutWithTheSameEmailIsToldToSetAPassword()]
removed conditional - replaced equality check with false → KILLED

60

1.1
Location : guest
Killed by : com.rbctcsworld.ecommerce.auth.GuestCheckoutTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.auth.GuestCheckoutTest]/[method:twoGuestsNeverShareASecret()]
removed call to java/security/SecureRandom::nextBytes → KILLED

62

1.1
Location : guest
Killed by : com.rbctcsworld.ecommerce.auth.GuestCheckoutTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.auth.GuestCheckoutTest]/[method:aGuestGetsACustomerSessionWithAPasswordNobodyKnows()]
replaced return value with null for com/rbctcsworld/ecommerce/auth/AuthService::guest → KILLED

76

1.1
Location : login
Killed by : com.rbctcsworld.ecommerce.auth.AuthServiceTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.auth.AuthServiceTest]/[method:lockedAccountIsRejectedBeforeThePasswordIsEvenChecked()]
removed call to com/rbctcsworld/ecommerce/auth/LoginAttemptService::checkAllowed → KILLED

79

1.1
Location : login
Killed by : com.rbctcsworld.ecommerce.auth.AuthServiceTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.auth.AuthServiceTest]/[method:loginWithWrongPasswordIsInvalidCredentialsAndCountsAsFailure()]
removed conditional - replaced equality check with false → KILLED

85

1.1
Location : login
Killed by : com.rbctcsworld.ecommerce.auth.AuthServiceTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.auth.AuthServiceTest]/[method:loginWithWrongPasswordIsInvalidCredentialsAndCountsAsFailure()]
removed conditional - replaced equality check with false → KILLED

86

1.1
Location : login
Killed by : com.rbctcsworld.ecommerce.auth.AuthServiceTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.auth.AuthServiceTest]/[method:loginWithWrongPasswordIsInvalidCredentialsAndCountsAsFailure()]
removed call to com/rbctcsworld/ecommerce/auth/LoginAttemptService::loginFailed → KILLED

89

1.1
Location : login
Killed by : com.rbctcsworld.ecommerce.auth.AuthServiceTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.auth.AuthServiceTest]/[method:successfulLoginClearsFailures()]
removed call to com/rbctcsworld/ecommerce/auth/LoginAttemptService::loginSucceeded → KILLED

90

1.1
Location : login
Killed by : com.rbctcsworld.ecommerce.auth.AuthServiceTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.auth.AuthServiceTest]/[method:aDisabledAccountIsToldSoOnlyAfterTheRightPasswordAndGetsNoSession()]
removed conditional - replaced equality check with false → KILLED

94

1.1
Location : login
Killed by : none
removed conditional - replaced equality check with false → SURVIVED
Covering tests

98

1.1
Location : login
Killed by : com.rbctcsworld.ecommerce.auth.AuthServiceTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.auth.AuthServiceTest]/[method:successfulLoginClearsFailures()]
replaced return value with null for com/rbctcsworld/ecommerce/auth/AuthService::login → KILLED

104

1.1
Location : verifyLogin
Killed by : none
removed conditional - replaced equality check with false → NO_COVERAGE

107

1.1
Location : verifyLogin
Killed by : none
removed call to com/rbctcsworld/ecommerce/auth/LoginAttemptService::loginSucceeded → NO_COVERAGE

108

1.1
Location : verifyLogin
Killed by : none
replaced return value with null for com/rbctcsworld/ecommerce/auth/AuthService::verifyLogin → NO_COVERAGE

114

1.1
Location : refresh
Killed by : com.rbctcsworld.ecommerce.auth.AuthServiceTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.auth.AuthServiceTest]/[method:aDisabledAccountCannotRefreshItsSession()]
removed conditional - replaced equality check with false → KILLED

117

1.1
Location : refresh
Killed by : com.rbctcsworld.ecommerce.auth.AuthServiceTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.auth.AuthServiceTest]/[method:refreshReturnsANewAccessTokenAndTheRotatedRefreshToken()]
replaced return value with null for com/rbctcsworld/ecommerce/auth/AuthService::refresh → KILLED

122

1.1
Location : logout
Killed by : com.rbctcsworld.ecommerce.auth.AuthServiceTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.auth.AuthServiceTest]/[method:logoutRevokesTheRefreshToken()]
removed call to com/rbctcsworld/ecommerce/auth/RefreshTokenService::revoke → KILLED

126

1.1
Location : result
Killed by : com.rbctcsworld.ecommerce.auth.AuthServiceTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.auth.AuthServiceTest]/[method:registerNormalizesEmailAndCreatesCustomer()]
replaced return value with null for com/rbctcsworld/ecommerce/auth/AuthService::result → KILLED

131

1.1
Location : dummyHash
Killed by : com.rbctcsworld.ecommerce.auth.AuthServiceTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.auth.AuthServiceTest]/[method:unknownEmailStillRunsAPasswordCheckSoTimingRevealsNothing()]
removed conditional - replaced equality check with false → KILLED

134

1.1
Location : dummyHash
Killed by : com.rbctcsworld.ecommerce.auth.AuthServiceTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.auth.AuthServiceTest]/[method:unknownEmailStillRunsAPasswordCheckSoTimingRevealsNothing()]
replaced return value with "" for com/rbctcsworld/ecommerce/auth/AuthService::dummyHash → KILLED

138

1.1
Location : normalize
Killed by : com.rbctcsworld.ecommerce.auth.AuthServiceTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.auth.AuthServiceTest]/[method:lockedAccountIsRejectedBeforeThePasswordIsEvenChecked()]
replaced return value with "" for com/rbctcsworld/ecommerce/auth/AuthService::normalize → KILLED

2.2
Location : normalize
Killed by : com.rbctcsworld.ecommerce.auth.AuthServiceTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.auth.AuthServiceTest]/[method:missingEmailIsAnInvalidLoginNotACrash()]
removed conditional - replaced equality check with false → KILLED

Active mutators

Tests examined


Report generated by PIT 1.30.0 support