| 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 |
|
| 40 |
1.1 |
|
| 56 |
1.1 |
|
| 57 |
1.1 |
|
| 60 |
1.1 |
|
| 62 |
1.1 |
|
| 76 |
1.1 |
|
| 79 |
1.1 |
|
| 85 |
1.1 |
|
| 86 |
1.1 |
|
| 89 |
1.1 |
|
| 90 |
1.1 |
|
| 94 |
1.1 |
|
| 98 |
1.1 |
|
| 104 |
1.1 |
|
| 107 |
1.1 |
|
| 108 |
1.1 |
|
| 114 |
1.1 |
|
| 117 |
1.1 |
|
| 122 |
1.1 |
|
| 126 |
1.1 |
|
| 131 |
1.1 |
|
| 134 |
1.1 |
|
| 138 |
1.1 2.2 |