TwoFactorService.java

package com.rbctcsworld.ecommerce.auth;

import com.rbctcsworld.ecommerce.common.exception.BusinessRuleException;
import com.rbctcsworld.ecommerce.common.exception.InvalidCredentialsException;
import com.rbctcsworld.ecommerce.common.exception.TooManyRequestsException;
import com.rbctcsworld.ecommerce.notification.AccountEmailEvent;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.SecureRandom;
import java.time.Clock;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.Base64;

/**
 * Two-factor sign-in by e-mail code (opt-in, REQ-AUTH-16).
 *
 *  - After the right password, a 6-digit code goes to the account's e-mail and the browser gets a random
 *    challenge id; both are needed to finish the sign-in.
 *  - Only a SHA-256 hash of challenge id + code is stored; the delivered e-mail body is redacted.
 *  - A challenge works once, for 10 minutes and for 5 wrong guesses (counted in the database); an unknown, used,
 *    expired or exhausted challenge all get the same 400.
 *  - At most 5 codes per account and hour (429), and a new code ends older open ones.
 */
@Service
public class TwoFactorService {

    static final Duration LIFETIME = Duration.ofMinutes(10);
    static final int MAX_GUESSES = 5;
    static final int MAX_CODES_PER_HOUR = 5;
    static final String INVALID = "This sign-in code is wrong or has expired. Please sign in again.";
    static final String GUEST = "Set a password first (use \"Forgot password\"), then turn on two-factor sign-in.";

    private static final SecureRandom RANDOM = new SecureRandom();

    /** What the browser keeps between the two steps. */
    public record Challenge(boolean twoFactorRequired, String challengeToken, String message) {
    }

    private final UserRepository users;
    private final LoginChallengeRepository challenges;
    private final PasswordEncoder encoder;
    private final ApplicationEventPublisher events;
    private final Clock clock;

    public TwoFactorService(UserRepository users, LoginChallengeRepository challenges, PasswordEncoder encoder,
                            ApplicationEventPublisher events, Clock clock) {
        this.users = users;
        this.challenges = challenges;
        this.encoder = encoder;
        this.events = events;
        this.clock = clock;
    }

    /** Sends a new code and returns the challenge id. 429 after too many codes in an hour. */
    @Transactional
    public Challenge begin(AppUser user) {
        LocalDateTime now = LocalDateTime.now(clock);
        if (challenges.countByUserIdAndCreatedAtAfter(user.getId(), now.minusHours(1)) >= MAX_CODES_PER_HOUR) {
            throw new TooManyRequestsException("Too many sign-in codes. Please try again later.", 3600);
        }
        challenges.useAllOf(user.getId(), now);
        String challengeToken = randomToken();
        String code = String.format("%06d", RANDOM.nextInt(1_000_000));
        challenges.save(new LoginChallenge(user.getId(), RefreshTokenService.hash(challengeToken),
                codeHash(challengeToken, code), now, now.plus(LIFETIME)));
        events.publishEvent(new AccountEmailEvent(user.getEmail(), AccountEmailEvent.Type.LOGIN_CODE,
                "Your RbcTcsWorld sign-in code",
                "Your sign-in code is " + code + "\n\nIt works once, for 10 minutes.\n\n"
                        + "Did not try to sign in? Someone knows your password - change it with \"Forgot password\"."));
        return new Challenge(true, challengeToken, "A 6-digit code was sent to your e-mail.");
    }

    /** Checks the code; returns the account to sign in. 400 INVALID for every kind of failure. */
    @Transactional(noRollbackFor = BusinessRuleException.class)
    public AppUser verify(String challengeToken, String code) {
        LocalDateTime now = LocalDateTime.now(clock);
        LoginChallenge c = challengeToken == null || challengeToken.isBlank() ? null
                : challenges.findByChallengeHash(RefreshTokenService.hash(challengeToken)).orElse(null);
        if (c == null || c.getUsedAt() != null || !now.isBefore(c.getExpiresAt()) || c.getAttempts() >= MAX_GUESSES) {
            throw new BusinessRuleException(INVALID);
        }
        boolean right = code != null && MessageDigest.isEqual(
                codeHash(challengeToken, code.trim()).getBytes(StandardCharsets.UTF_8), c.getCodeHash().getBytes(StandardCharsets.UTF_8));
        if (!right) {
            challenges.countAttempt(c.getId());
            throw new BusinessRuleException(INVALID);
        }
        if (challenges.useIfUnused(c.getId(), now) == 0) {
            throw new BusinessRuleException(INVALID);
        }
        return users.findById(c.getUserId()).orElseThrow(() -> new BusinessRuleException(INVALID));
    }

    /** Turns two-factor sign-in on or off. Needs the current password; a guest has none yet. */
    @Transactional
    public boolean setEnabled(String email, boolean enabled, String password) {
        AppUser user = users.findByEmail(email == null ? "" : email.trim().toLowerCase())
                .orElseThrow(InvalidCredentialsException::new);
        if (user.isGuest()) {
            throw new BusinessRuleException(GUEST);
        }
        if (!encoder.matches(password == null ? "" : password, user.getPassword())) {
            throw new InvalidCredentialsException();
        }
        if (user.isTwoFactorEnabled() != enabled) {
            user.setTwoFactorEnabled(enabled);
            users.save(user);
            events.publishEvent(new AccountEmailEvent(user.getEmail(), AccountEmailEvent.Type.TWO_FACTOR_CHANGED,
                    "Two-factor sign-in turned " + (enabled ? "on" : "off"),
                    "Two-factor sign-in was just turned " + (enabled ? "on" : "off") + " for your RbcTcsWorld account.\n\n"
                            + "Was this not you? Change your password with \"Forgot password\" right away."));
        }
        return enabled;
    }

    @Transactional(readOnly = true)
    public boolean isEnabled(String email) {
        return users.findByEmail(email == null ? "" : email.trim().toLowerCase()).map(AppUser::isTwoFactorEnabled).orElse(false);
    }

    /** Hash of challenge id + code: the database alone cannot be used to find the 6 digits. */
    static String codeHash(String challengeToken, String code) {
        return RefreshTokenService.hash(challengeToken + ":" + code);
    }

    private static String randomToken() {
        byte[] bytes = new byte[32];
        RANDOM.nextBytes(bytes);
        return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
    }
}