| 1 | package com.rbctcsworld.ecommerce.auth; | |
| 2 | ||
| 3 | import com.rbctcsworld.ecommerce.common.exception.BusinessRuleException; | |
| 4 | import com.rbctcsworld.ecommerce.notification.AccountEmailEvent; | |
| 5 | import org.springframework.beans.factory.annotation.Value; | |
| 6 | import org.springframework.context.ApplicationEventPublisher; | |
| 7 | import org.springframework.security.crypto.password.PasswordEncoder; | |
| 8 | import org.springframework.stereotype.Service; | |
| 9 | import org.springframework.transaction.annotation.Transactional; | |
| 10 | ||
| 11 | import java.security.SecureRandom; | |
| 12 | import java.time.Clock; | |
| 13 | import java.time.Duration; | |
| 14 | import java.time.LocalDateTime; | |
| 15 | import java.util.Base64; | |
| 16 | ||
| 17 | /** | |
| 18 | * "Forgot password" by e-mail. | |
| 19 | * | |
| 20 | * Request (POST /api/auth/forgot-password): | |
| 21 | * - the answer is the same whether or not the e-mail has an account, so nobody can find out who is a customer; | |
| 22 | * - a link is a random 256-bit token; only its SHA-256 hash is stored; | |
| 23 | * - at most MAX_PER_HOUR links per account and hour (silently: the answer stays the same); | |
| 24 | * - a new link makes older open links unusable. | |
| 25 | * Reset (POST /api/auth/reset-password): | |
| 26 | * - the link works once and for LIFETIME; an unknown, used or expired link gets the same 400; | |
| 27 | * - the new password follows the registration rule (8-72 characters); | |
| 28 | * - every session ends (refresh tokens revoked), a login lockout is lifted, and the customer gets a | |
| 29 | * "your password was changed" e-mail - if it was not them, they know at once. | |
| 30 | * The e-mails go through the outbox (same transaction), and the delivered reset e-mail is redacted. | |
| 31 | */ | |
| 32 | @Service | |
| 33 | public class PasswordResetService { | |
| 34 | ||
| 35 | static final Duration LIFETIME = Duration.ofMinutes(30); | |
| 36 | static final int MAX_PER_HOUR = 3; | |
| 37 | static final String INVALID_LINK = "This reset link is invalid or has expired. Please ask for a new one."; | |
| 38 | ||
| 39 | private static final SecureRandom RANDOM = new SecureRandom(); | |
| 40 | ||
| 41 | private final UserRepository users; | |
| 42 | private final PasswordResetTokenRepository tokens; | |
| 43 | private final RefreshTokenRepository refreshTokens; | |
| 44 | private final PasswordEncoder encoder; | |
| 45 | private final LoginAttemptService attempts; | |
| 46 | private final ApplicationEventPublisher events; | |
| 47 | private final Clock clock; | |
| 48 | private final String publicUrl; | |
| 49 | ||
| 50 | public PasswordResetService(UserRepository users, PasswordResetTokenRepository tokens, | |
| 51 | RefreshTokenRepository refreshTokens, PasswordEncoder encoder, | |
| 52 | LoginAttemptService attempts, ApplicationEventPublisher events, Clock clock, | |
| 53 | @Value("${app.public-url:http://localhost:5173}") String publicUrl) { | |
| 54 | this.users = users; | |
| 55 | this.tokens = tokens; | |
| 56 | this.refreshTokens = refreshTokens; | |
| 57 | this.encoder = encoder; | |
| 58 | this.attempts = attempts; | |
| 59 | this.events = events; | |
| 60 | this.clock = clock; | |
| 61 |
2
1. <init> : removed conditional - replaced equality check with false → KILLED 2. <init> : Replaced integer subtraction with addition → KILLED |
this.publicUrl = publicUrl.endsWith("/") ? publicUrl.substring(0, publicUrl.length() - 1) : publicUrl; |
| 62 | } | |
| 63 | ||
| 64 | /** Sends a reset link if the account exists and the hourly limit allows it. Never says which happened. */ | |
| 65 | @Transactional | |
| 66 | public void requestReset(String rawEmail) { | |
| 67 |
1
1. requestReset : removed conditional - replaced equality check with false → KILLED |
String email = rawEmail == null ? "" : rawEmail.trim().toLowerCase(); |
| 68 | AppUser user = users.findByEmail(email).orElse(null); | |
| 69 |
1
1. requestReset : removed conditional - replaced equality check with false → KILLED |
if (user == null) { |
| 70 | return; | |
| 71 | } | |
| 72 | LocalDateTime now = LocalDateTime.now(clock); | |
| 73 |
2
1. requestReset : removed conditional - replaced comparison check with false → KILLED 2. requestReset : changed conditional boundary → KILLED |
if (tokens.countByUserIdAndCreatedAtAfter(user.getId(), now.minusHours(1)) >= MAX_PER_HOUR) { |
| 74 | return; | |
| 75 | } | |
| 76 | tokens.useAllOf(user.getId(), now); | |
| 77 | String raw = newToken(); | |
| 78 | tokens.save(new PasswordResetToken(user.getId(), RefreshTokenService.hash(raw), now, now.plus(LIFETIME))); | |
| 79 |
1
1. requestReset : removed call to org/springframework/context/ApplicationEventPublisher::publishEvent → KILLED |
events.publishEvent(new AccountEmailEvent(user.getEmail(), AccountEmailEvent.Type.PASSWORD_RESET, |
| 80 | "Reset your RbcTcsWorld password", | |
| 81 | "Someone asked to reset the password of your RbcTcsWorld account.\n\n" | |
| 82 | + "Choose a new password here (the link works once, for 30 minutes):\n" | |
| 83 | + publicUrl + "/#/reset-password?token=" + raw + "\n\n" | |
| 84 | + "Did not ask for this? Ignore this e-mail - your password stays the same.")); | |
| 85 | } | |
| 86 | ||
| 87 | /** Sets the new password with a valid link; 400 INVALID_LINK otherwise. */ | |
| 88 | @Transactional | |
| 89 | public void resetPassword(String rawToken, String newPassword) { | |
| 90 | LocalDateTime now = LocalDateTime.now(clock); | |
| 91 |
2
1. resetPassword : removed conditional - replaced equality check with false → KILLED 2. resetPassword : removed conditional - replaced equality check with false → KILLED |
PasswordResetToken token = rawToken == null || rawToken.isBlank() ? null |
| 92 | : tokens.findByTokenHash(RefreshTokenService.hash(rawToken)).orElse(null); | |
| 93 |
3
1. resetPassword : removed conditional - replaced equality check with false → KILLED 2. resetPassword : removed conditional - replaced equality check with false → KILLED 3. resetPassword : removed conditional - replaced equality check with false → KILLED |
if (token == null || token.getUsedAt() != null || !now.isBefore(token.getExpiresAt()) |
| 94 |
1
1. resetPassword : removed conditional - replaced equality check with false → SURVIVED |
|| tokens.useIfUnused(token.getId(), now) == 0) { |
| 95 | throw new BusinessRuleException(INVALID_LINK); | |
| 96 | } | |
| 97 |
1
1. lambda$resetPassword$0 : replaced return value with null for com/rbctcsworld/ecommerce/auth/PasswordResetService::lambda$resetPassword$0 → KILLED |
AppUser user = users.findById(token.getUserId()).orElseThrow(() -> new BusinessRuleException(INVALID_LINK)); |
| 98 |
1
1. resetPassword : removed call to com/rbctcsworld/ecommerce/auth/AppUser::changePassword → KILLED |
user.changePassword(encoder.encode(newPassword)); |
| 99 |
1
1. resetPassword : removed call to com/rbctcsworld/ecommerce/auth/AppUser::claim → KILLED |
user.claim(); // a guest who sets a password now has a full account |
| 100 | users.save(user); | |
| 101 | tokens.useAllOf(user.getId(), now); | |
| 102 | refreshTokens.revokeAllOf(user.getId(), now); | |
| 103 |
1
1. resetPassword : removed call to com/rbctcsworld/ecommerce/auth/LoginAttemptService::loginSucceeded → KILLED |
attempts.loginSucceeded(user.getEmail()); |
| 104 |
1
1. resetPassword : removed call to org/springframework/context/ApplicationEventPublisher::publishEvent → KILLED |
events.publishEvent(new AccountEmailEvent(user.getEmail(), AccountEmailEvent.Type.PASSWORD_CHANGED, |
| 105 | "Your RbcTcsWorld password was changed", | |
| 106 | "The password of your RbcTcsWorld account was just changed, and every device was signed out.\n\n" | |
| 107 | + "Was this not you? Ask for a new reset link on the login page right away and reply to this e-mail.")); | |
| 108 | } | |
| 109 | ||
| 110 | private static String newToken() { | |
| 111 | byte[] bytes = new byte[32]; | |
| 112 |
1
1. newToken : removed call to java/security/SecureRandom::nextBytes → KILLED |
RANDOM.nextBytes(bytes); |
| 113 |
1
1. newToken : replaced return value with "" for com/rbctcsworld/ecommerce/auth/PasswordResetService::newToken → KILLED |
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); |
| 114 | } | |
| 115 | } | |
Mutations | ||
| 61 |
1.1 2.2 |
|
| 67 |
1.1 |
|
| 69 |
1.1 |
|
| 73 |
1.1 2.2 |
|
| 79 |
1.1 |
|
| 91 |
1.1 2.2 |
|
| 93 |
1.1 2.2 3.3 |
|
| 94 |
1.1 |
|
| 97 |
1.1 |
|
| 98 |
1.1 |
|
| 99 |
1.1 |
|
| 103 |
1.1 |
|
| 104 |
1.1 |
|
| 112 |
1.1 |
|
| 113 |
1.1 |