PasswordResetService.java

package com.rbctcsworld.ecommerce.auth;

import com.rbctcsworld.ecommerce.common.exception.BusinessRuleException;
import com.rbctcsworld.ecommerce.notification.AccountEmailEvent;
import org.springframework.beans.factory.annotation.Value;
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.security.SecureRandom;
import java.time.Clock;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.Base64;

/**
 * "Forgot password" by e-mail.
 *
 * Request (POST /api/auth/forgot-password):
 *  - the answer is the same whether or not the e-mail has an account, so nobody can find out who is a customer;
 *  - a link is a random 256-bit token; only its SHA-256 hash is stored;
 *  - at most MAX_PER_HOUR links per account and hour (silently: the answer stays the same);
 *  - a new link makes older open links unusable.
 * Reset (POST /api/auth/reset-password):
 *  - the link works once and for LIFETIME; an unknown, used or expired link gets the same 400;
 *  - the new password follows the registration rule (8-72 characters);
 *  - every session ends (refresh tokens revoked), a login lockout is lifted, and the customer gets a
 *    "your password was changed" e-mail - if it was not them, they know at once.
 * The e-mails go through the outbox (same transaction), and the delivered reset e-mail is redacted.
 */
@Service
public class PasswordResetService {

    static final Duration LIFETIME = Duration.ofMinutes(30);
    static final int MAX_PER_HOUR = 3;
    static final String INVALID_LINK = "This reset link is invalid or has expired. Please ask for a new one.";

    private static final SecureRandom RANDOM = new SecureRandom();

    private final UserRepository users;
    private final PasswordResetTokenRepository tokens;
    private final RefreshTokenRepository refreshTokens;
    private final PasswordEncoder encoder;
    private final LoginAttemptService attempts;
    private final ApplicationEventPublisher events;
    private final Clock clock;
    private final String publicUrl;

    public PasswordResetService(UserRepository users, PasswordResetTokenRepository tokens,
                                RefreshTokenRepository refreshTokens, PasswordEncoder encoder,
                                LoginAttemptService attempts, ApplicationEventPublisher events, Clock clock,
                                @Value("${app.public-url:http://localhost:5173}") String publicUrl) {
        this.users = users;
        this.tokens = tokens;
        this.refreshTokens = refreshTokens;
        this.encoder = encoder;
        this.attempts = attempts;
        this.events = events;
        this.clock = clock;
        this.publicUrl = publicUrl.endsWith("/") ? publicUrl.substring(0, publicUrl.length() - 1) : publicUrl;
    }

    /** Sends a reset link if the account exists and the hourly limit allows it. Never says which happened. */
    @Transactional
    public void requestReset(String rawEmail) {
        String email = rawEmail == null ? "" : rawEmail.trim().toLowerCase();
        AppUser user = users.findByEmail(email).orElse(null);
        if (user == null) {
            return;
        }
        LocalDateTime now = LocalDateTime.now(clock);
        if (tokens.countByUserIdAndCreatedAtAfter(user.getId(), now.minusHours(1)) >= MAX_PER_HOUR) {
            return;
        }
        tokens.useAllOf(user.getId(), now);
        String raw = newToken();
        tokens.save(new PasswordResetToken(user.getId(), RefreshTokenService.hash(raw), now, now.plus(LIFETIME)));
        events.publishEvent(new AccountEmailEvent(user.getEmail(), AccountEmailEvent.Type.PASSWORD_RESET,
                "Reset your RbcTcsWorld password",
                "Someone asked to reset the password of your RbcTcsWorld account.\n\n"
                        + "Choose a new password here (the link works once, for 30 minutes):\n"
                        + publicUrl + "/#/reset-password?token=" + raw + "\n\n"
                        + "Did not ask for this? Ignore this e-mail - your password stays the same."));
    }

    /** Sets the new password with a valid link; 400 INVALID_LINK otherwise. */
    @Transactional
    public void resetPassword(String rawToken, String newPassword) {
        LocalDateTime now = LocalDateTime.now(clock);
        PasswordResetToken token = rawToken == null || rawToken.isBlank() ? null
                : tokens.findByTokenHash(RefreshTokenService.hash(rawToken)).orElse(null);
        if (token == null || token.getUsedAt() != null || !now.isBefore(token.getExpiresAt())
                || tokens.useIfUnused(token.getId(), now) == 0) {
            throw new BusinessRuleException(INVALID_LINK);
        }
        AppUser user = users.findById(token.getUserId()).orElseThrow(() -> new BusinessRuleException(INVALID_LINK));
        user.changePassword(encoder.encode(newPassword));
        user.claim();                                   // a guest who sets a password now has a full account
        users.save(user);
        tokens.useAllOf(user.getId(), now);
        refreshTokens.revokeAllOf(user.getId(), now);
        attempts.loginSucceeded(user.getEmail());
        events.publishEvent(new AccountEmailEvent(user.getEmail(), AccountEmailEvent.Type.PASSWORD_CHANGED,
                "Your RbcTcsWorld password was changed",
                "The password of your RbcTcsWorld account was just changed, and every device was signed out.\n\n"
                        + "Was this not you? Ask for a new reset link on the login page right away and reply to this e-mail."));
    }

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