TwoFactorService.java

1
package com.rbctcsworld.ecommerce.auth;
2
3
import com.rbctcsworld.ecommerce.common.exception.BusinessRuleException;
4
import com.rbctcsworld.ecommerce.common.exception.InvalidCredentialsException;
5
import com.rbctcsworld.ecommerce.common.exception.TooManyRequestsException;
6
import com.rbctcsworld.ecommerce.notification.AccountEmailEvent;
7
import org.springframework.context.ApplicationEventPublisher;
8
import org.springframework.security.crypto.password.PasswordEncoder;
9
import org.springframework.stereotype.Service;
10
import org.springframework.transaction.annotation.Transactional;
11
12
import java.nio.charset.StandardCharsets;
13
import java.security.MessageDigest;
14
import java.security.SecureRandom;
15
import java.time.Clock;
16
import java.time.Duration;
17
import java.time.LocalDateTime;
18
import java.util.Base64;
19
20
/**
21
 * Two-factor sign-in by e-mail code (opt-in, REQ-AUTH-16).
22
 *
23
 *  - After the right password, a 6-digit code goes to the account's e-mail and the browser gets a random
24
 *    challenge id; both are needed to finish the sign-in.
25
 *  - Only a SHA-256 hash of challenge id + code is stored; the delivered e-mail body is redacted.
26
 *  - A challenge works once, for 10 minutes and for 5 wrong guesses (counted in the database); an unknown, used,
27
 *    expired or exhausted challenge all get the same 400.
28
 *  - At most 5 codes per account and hour (429), and a new code ends older open ones.
29
 */
30
@Service
31
public class TwoFactorService {
32
33
    static final Duration LIFETIME = Duration.ofMinutes(10);
34
    static final int MAX_GUESSES = 5;
35
    static final int MAX_CODES_PER_HOUR = 5;
36
    static final String INVALID = "This sign-in code is wrong or has expired. Please sign in again.";
37
    static final String GUEST = "Set a password first (use \"Forgot password\"), then turn on two-factor sign-in.";
38
39
    private static final SecureRandom RANDOM = new SecureRandom();
40
41
    /** What the browser keeps between the two steps. */
42
    public record Challenge(boolean twoFactorRequired, String challengeToken, String message) {
43
    }
44
45
    private final UserRepository users;
46
    private final LoginChallengeRepository challenges;
47
    private final PasswordEncoder encoder;
48
    private final ApplicationEventPublisher events;
49
    private final Clock clock;
50
51
    public TwoFactorService(UserRepository users, LoginChallengeRepository challenges, PasswordEncoder encoder,
52
                            ApplicationEventPublisher events, Clock clock) {
53
        this.users = users;
54
        this.challenges = challenges;
55
        this.encoder = encoder;
56
        this.events = events;
57
        this.clock = clock;
58
    }
59
60
    /** Sends a new code and returns the challenge id. 429 after too many codes in an hour. */
61
    @Transactional
62
    public Challenge begin(AppUser user) {
63
        LocalDateTime now = LocalDateTime.now(clock);
64 2 1. begin : removed conditional - replaced comparison check with false → KILLED
2. begin : changed conditional boundary → KILLED
        if (challenges.countByUserIdAndCreatedAtAfter(user.getId(), now.minusHours(1)) >= MAX_CODES_PER_HOUR) {
65
            throw new TooManyRequestsException("Too many sign-in codes. Please try again later.", 3600);
66
        }
67
        challenges.useAllOf(user.getId(), now);
68
        String challengeToken = randomToken();
69
        String code = String.format("%06d", RANDOM.nextInt(1_000_000));
70
        challenges.save(new LoginChallenge(user.getId(), RefreshTokenService.hash(challengeToken),
71
                codeHash(challengeToken, code), now, now.plus(LIFETIME)));
72 1 1. begin : removed call to org/springframework/context/ApplicationEventPublisher::publishEvent → KILLED
        events.publishEvent(new AccountEmailEvent(user.getEmail(), AccountEmailEvent.Type.LOGIN_CODE,
73
                "Your RbcTcsWorld sign-in code",
74
                "Your sign-in code is " + code + "\n\nIt works once, for 10 minutes.\n\n"
75
                        + "Did not try to sign in? Someone knows your password - change it with \"Forgot password\"."));
76 1 1. begin : replaced return value with null for com/rbctcsworld/ecommerce/auth/TwoFactorService::begin → KILLED
        return new Challenge(true, challengeToken, "A 6-digit code was sent to your e-mail.");
77
    }
78
79
    /** Checks the code; returns the account to sign in. 400 INVALID for every kind of failure. */
80
    @Transactional(noRollbackFor = BusinessRuleException.class)
81
    public AppUser verify(String challengeToken, String code) {
82
        LocalDateTime now = LocalDateTime.now(clock);
83 2 1. verify : removed conditional - replaced equality check with false → SURVIVED
2. verify : removed conditional - replaced equality check with false → KILLED
        LoginChallenge c = challengeToken == null || challengeToken.isBlank() ? null
84
                : challenges.findByChallengeHash(RefreshTokenService.hash(challengeToken)).orElse(null);
85 5 1. verify : changed conditional boundary → KILLED
2. verify : removed conditional - replaced equality check with false → KILLED
3. verify : removed conditional - replaced equality check with false → KILLED
4. verify : removed conditional - replaced comparison check with false → KILLED
5. verify : removed conditional - replaced equality check with false → KILLED
        if (c == null || c.getUsedAt() != null || !now.isBefore(c.getExpiresAt()) || c.getAttempts() >= MAX_GUESSES) {
86
            throw new BusinessRuleException(INVALID);
87
        }
88 2 1. verify : removed conditional - replaced equality check with false → KILLED
2. verify : removed conditional - replaced equality check with false → KILLED
        boolean right = code != null && MessageDigest.isEqual(
89
                codeHash(challengeToken, code.trim()).getBytes(StandardCharsets.UTF_8), c.getCodeHash().getBytes(StandardCharsets.UTF_8));
90 1 1. verify : removed conditional - replaced equality check with false → KILLED
        if (!right) {
91
            challenges.countAttempt(c.getId());
92
            throw new BusinessRuleException(INVALID);
93
        }
94 1 1. verify : removed conditional - replaced equality check with false → SURVIVED
        if (challenges.useIfUnused(c.getId(), now) == 0) {
95
            throw new BusinessRuleException(INVALID);
96
        }
97 2 1. lambda$verify$0 : replaced return value with null for com/rbctcsworld/ecommerce/auth/TwoFactorService::lambda$verify$0 → NO_COVERAGE
2. verify : replaced return value with null for com/rbctcsworld/ecommerce/auth/TwoFactorService::verify → KILLED
        return users.findById(c.getUserId()).orElseThrow(() -> new BusinessRuleException(INVALID));
98
    }
99
100
    /** Turns two-factor sign-in on or off. Needs the current password; a guest has none yet. */
101
    @Transactional
102
    public boolean setEnabled(String email, boolean enabled, String password) {
103 1 1. setEnabled : removed conditional - replaced equality check with false → SURVIVED
        AppUser user = users.findByEmail(email == null ? "" : email.trim().toLowerCase())
104
                .orElseThrow(InvalidCredentialsException::new);
105 1 1. setEnabled : removed conditional - replaced equality check with false → KILLED
        if (user.isGuest()) {
106
            throw new BusinessRuleException(GUEST);
107
        }
108 2 1. setEnabled : removed conditional - replaced equality check with false → SURVIVED
2. setEnabled : removed conditional - replaced equality check with false → KILLED
        if (!encoder.matches(password == null ? "" : password, user.getPassword())) {
109
            throw new InvalidCredentialsException();
110
        }
111 1 1. setEnabled : removed conditional - replaced equality check with false → KILLED
        if (user.isTwoFactorEnabled() != enabled) {
112 1 1. setEnabled : removed call to com/rbctcsworld/ecommerce/auth/AppUser::setTwoFactorEnabled → KILLED
            user.setTwoFactorEnabled(enabled);
113
            users.save(user);
114 1 1. setEnabled : removed call to org/springframework/context/ApplicationEventPublisher::publishEvent → KILLED
            events.publishEvent(new AccountEmailEvent(user.getEmail(), AccountEmailEvent.Type.TWO_FACTOR_CHANGED,
115 1 1. setEnabled : removed conditional - replaced equality check with false → SURVIVED
                    "Two-factor sign-in turned " + (enabled ? "on" : "off"),
116 1 1. setEnabled : removed conditional - replaced equality check with false → SURVIVED
                    "Two-factor sign-in was just turned " + (enabled ? "on" : "off") + " for your RbcTcsWorld account.\n\n"
117
                            + "Was this not you? Change your password with \"Forgot password\" right away."));
118
        }
119 2 1. setEnabled : replaced boolean return with true for com/rbctcsworld/ecommerce/auth/TwoFactorService::setEnabled → SURVIVED
2. setEnabled : replaced boolean return with false for com/rbctcsworld/ecommerce/auth/TwoFactorService::setEnabled → KILLED
        return enabled;
120
    }
121
122
    @Transactional(readOnly = true)
123
    public boolean isEnabled(String email) {
124 3 1. isEnabled : replaced boolean return with true for com/rbctcsworld/ecommerce/auth/TwoFactorService::isEnabled → NO_COVERAGE
2. isEnabled : removed conditional - replaced equality check with false → NO_COVERAGE
3. isEnabled : replaced boolean return with false for com/rbctcsworld/ecommerce/auth/TwoFactorService::isEnabled → NO_COVERAGE
        return users.findByEmail(email == null ? "" : email.trim().toLowerCase()).map(AppUser::isTwoFactorEnabled).orElse(false);
125
    }
126
127
    /** Hash of challenge id + code: the database alone cannot be used to find the 6 digits. */
128
    static String codeHash(String challengeToken, String code) {
129 1 1. codeHash : replaced return value with "" for com/rbctcsworld/ecommerce/auth/TwoFactorService::codeHash → KILLED
        return RefreshTokenService.hash(challengeToken + ":" + code);
130
    }
131
132
    private static String randomToken() {
133
        byte[] bytes = new byte[32];
134 1 1. randomToken : removed call to java/security/SecureRandom::nextBytes → SURVIVED
        RANDOM.nextBytes(bytes);
135 1 1. randomToken : replaced return value with "" for com/rbctcsworld/ecommerce/auth/TwoFactorService::randomToken → KILLED
        return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
136
    }
137
}

Mutations

64

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

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

72

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

76

1.1
Location : begin
Killed by : com.rbctcsworld.ecommerce.auth.TwoFactorServiceTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.auth.TwoFactorServiceTest]/[method:beginEmailsASixDigitCodeAndStoresOnlyHashes()]
replaced return value with null for com/rbctcsworld/ecommerce/auth/TwoFactorService::begin → KILLED

83

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

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

85

1.1
Location : verify
Killed by : com.rbctcsworld.ecommerce.auth.TwoFactorServiceTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.auth.TwoFactorServiceTest]/[method:afterFiveWrongGuessesEvenTheRightCodeIsRefused()]
changed conditional boundary → KILLED

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

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

4.4
Location : verify
Killed by : com.rbctcsworld.ecommerce.auth.TwoFactorServiceTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.auth.TwoFactorServiceTest]/[method:afterFiveWrongGuessesEvenTheRightCodeIsRefused()]
removed conditional - replaced comparison check with false → KILLED

5.5
Location : verify
Killed by : com.rbctcsworld.ecommerce.auth.TwoFactorServiceTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.auth.TwoFactorServiceTest]/[method:twoParallelRequestsCannotBothUseTheCode()]
removed conditional - replaced equality check with false → KILLED

88

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

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

90

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

94

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

97

1.1
Location : verify
Killed by : com.rbctcsworld.ecommerce.auth.TwoFactorServiceTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.auth.TwoFactorServiceTest]/[method:theRightCodeSignsTheUserIn()]
replaced return value with null for com/rbctcsworld/ecommerce/auth/TwoFactorService::verify → KILLED

2.2
Location : lambda$verify$0
Killed by : none
replaced return value with null for com/rbctcsworld/ecommerce/auth/TwoFactorService::lambda$verify$0 → NO_COVERAGE

103

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

105

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

108

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

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

111

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

112

1.1
Location : setEnabled
Killed by : com.rbctcsworld.ecommerce.auth.TwoFactorServiceTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.auth.TwoFactorServiceTest]/[method:turnOnNeedsThePasswordAndSendsAnEmail()]
removed call to com/rbctcsworld/ecommerce/auth/AppUser::setTwoFactorEnabled → KILLED

114

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

115

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

116

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

119

1.1
Location : setEnabled
Killed by : none
replaced boolean return with true for com/rbctcsworld/ecommerce/auth/TwoFactorService::setEnabled → SURVIVED
Covering tests

2.2
Location : setEnabled
Killed by : com.rbctcsworld.ecommerce.auth.TwoFactorServiceTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.auth.TwoFactorServiceTest]/[method:turnOnNeedsThePasswordAndSendsAnEmail()]
replaced boolean return with false for com/rbctcsworld/ecommerce/auth/TwoFactorService::setEnabled → KILLED

124

1.1
Location : isEnabled
Killed by : none
replaced boolean return with true for com/rbctcsworld/ecommerce/auth/TwoFactorService::isEnabled → NO_COVERAGE

2.2
Location : isEnabled
Killed by : none
removed conditional - replaced equality check with false → NO_COVERAGE

3.3
Location : isEnabled
Killed by : none
replaced boolean return with false for com/rbctcsworld/ecommerce/auth/TwoFactorService::isEnabled → NO_COVERAGE

129

1.1
Location : codeHash
Killed by : com.rbctcsworld.ecommerce.auth.TwoFactorServiceTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.auth.TwoFactorServiceTest]/[method:beginEmailsASixDigitCodeAndStoresOnlyHashes()]
replaced return value with "" for com/rbctcsworld/ecommerce/auth/TwoFactorService::codeHash → KILLED

134

1.1
Location : randomToken
Killed by : none
removed call to java/security/SecureRandom::nextBytes → SURVIVED
Covering tests

135

1.1
Location : randomToken
Killed by : com.rbctcsworld.ecommerce.auth.TwoFactorServiceTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.auth.TwoFactorServiceTest]/[method:usedExpiredAndUnknownChallengesAllGetTheSameAnswer()]
replaced return value with "" for com/rbctcsworld/ecommerce/auth/TwoFactorService::randomToken → KILLED

Active mutators

Tests examined


Report generated by PIT 1.30.0 support