PasswordResetService.java

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

2.2
Location : <init>
Killed by : com.rbctcsworld.ecommerce.auth.PasswordResetServiceTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.auth.PasswordResetServiceTest]/[method:anUnknownEmailSilentlyGetsNothing()]
Replaced integer subtraction with addition → KILLED

67

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

69

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

73

1.1
Location : requestReset
Killed by : com.rbctcsworld.ecommerce.auth.PasswordResetServiceTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.auth.PasswordResetServiceTest]/[method:theThirdLinkInAnHourIsTheLast()]
removed conditional - replaced comparison check with false → KILLED

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

79

1.1
Location : requestReset
Killed by : com.rbctcsworld.ecommerce.auth.PasswordResetServiceTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.auth.PasswordResetServiceTest]/[method:thePublicUrlWithoutATrailingSlashIsUsedAsItIs()]
removed call to org/springframework/context/ApplicationEventPublisher::publishEvent → KILLED

91

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

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

93

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

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

3.3
Location : resetPassword
Killed by : com.rbctcsworld.ecommerce.auth.PasswordResetServiceTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.auth.PasswordResetServiceTest]/[method:aLinkUsedAtTheSameMomentByAnotherRequestIsRefused()]
removed conditional - replaced equality check with false → KILLED

94

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

97

1.1
Location : lambda$resetPassword$0
Killed by : com.rbctcsworld.ecommerce.auth.PasswordResetServiceTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.auth.PasswordResetServiceTest]/[method:aLinkOfADeletedAccountIsRefused()]
replaced return value with null for com/rbctcsworld/ecommerce/auth/PasswordResetService::lambda$resetPassword$0 → KILLED

98

1.1
Location : resetPassword
Killed by : com.rbctcsworld.ecommerce.auth.PasswordResetServiceTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.auth.PasswordResetServiceTest]/[method:aValidLinkSetsTheNewPasswordAndEndsEverySession()]
removed call to com/rbctcsworld/ecommerce/auth/AppUser::changePassword → KILLED

99

1.1
Location : resetPassword
Killed by : com.rbctcsworld.ecommerce.auth.PasswordResetServiceTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.auth.PasswordResetServiceTest]/[method:aGuestWhoSetsAPasswordNowHasAFullAccount()]
removed call to com/rbctcsworld/ecommerce/auth/AppUser::claim → KILLED

103

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

104

1.1
Location : resetPassword
Killed by : com.rbctcsworld.ecommerce.auth.PasswordResetServiceTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.auth.PasswordResetServiceTest]/[method:aValidLinkSetsTheNewPasswordAndEndsEverySession()]
removed call to org/springframework/context/ApplicationEventPublisher::publishEvent → KILLED

112

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

113

1.1
Location : newToken
Killed by : com.rbctcsworld.ecommerce.auth.PasswordResetServiceTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.auth.PasswordResetServiceTest]/[method:twoLinksGiveTwoDifferentTokens()]
replaced return value with "" for com/rbctcsworld/ecommerce/auth/PasswordResetService::newToken → KILLED

Active mutators

Tests examined


Report generated by PIT 1.30.0 support