AuthService.java

package com.rbctcsworld.ecommerce.auth;

import com.rbctcsworld.ecommerce.common.exception.ConflictException;
import com.rbctcsworld.ecommerce.common.exception.ForbiddenException;
import com.rbctcsworld.ecommerce.common.exception.InvalidCredentialsException;
import com.rbctcsworld.ecommerce.common.exception.InvalidRefreshTokenException;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;

@Service
public class AuthService {

    public static final String DISABLED = "This account is disabled. Please contact the store.";

    private final UserRepository repo;
    private final PasswordEncoder encoder;
    private final JwtService jwt;
    private final LoginAttemptService attempts;
    private final RefreshTokenService refreshTokens;
    private final TwoFactorService twoFactor;
    private volatile String dummyHash;

    public AuthService(UserRepository repo, PasswordEncoder encoder, JwtService jwt, LoginAttemptService attempts,
                       RefreshTokenService refreshTokens, TwoFactorService twoFactor) {
        this.repo = repo;
        this.encoder = encoder;
        this.jwt = jwt;
        this.attempts = attempts;
        this.refreshTokens = refreshTokens;
        this.twoFactor = twoFactor;
    }

    /** New accounts are always CUSTOMER; admins are only created by DataSeeder. */
    public AuthResult register(String rawEmail, String password) {
        String email = normalize(rawEmail);
        if (repo.findByEmail(email).isPresent()) {
            throw new ConflictException("Email already registered");
        }
        AppUser user = repo.save(new AppUser(email, encoder.encode(password)));
        return result(user, refreshTokens.issue(user));
    }

    public static final String GUEST_AGAIN = "You already checked out as a guest with this e-mail. "
            + "Use \"Forgot password\" to set a password and see your orders.";
    public static final String HAS_ACCOUNT = "This e-mail has an account. Please log in.";

    /**
     * Guest checkout: a customer account with a random password nobody knows. The guest can shop, pay and follow
     * the order in this browser (the session works like any other); "Forgot password" turns it into a full
     * account. An e-mail that already has an account - full or guest - is refused (409), so nobody can open
     * somebody else's orders by typing their e-mail.
     */
    public AuthResult guest(String rawEmail) {
        String email = normalize(rawEmail);
        AppUser existing = repo.findByEmail(email).orElse(null);
        if (existing != null) {
            throw new ConflictException(existing.isGuest() ? GUEST_AGAIN : HAS_ACCOUNT);
        }
        byte[] secret = new byte[32];
        RANDOM.nextBytes(secret);
        AppUser user = repo.save(AppUser.guest(email, encoder.encode(java.util.Base64.getEncoder().encodeToString(secret))));
        return result(user, refreshTokens.issue(user));
    }

    private static final java.security.SecureRandom RANDOM = new java.security.SecureRandom();

    /**
     * Attackers must not be able to discover which e-mails have accounts:
     *  - same 401 message for an unknown e-mail and a wrong password;
     *  - same TIME: for an unknown e-mail a bcrypt check still runs (against a dummy hash). Without it
     *    an unknown e-mail answered in ~1 ms and a real one in ~90 ms - measurable from outside.
     * Brute force: after 5 failures the e-mail is locked for 15 minutes (429), see LoginAttemptService.
     */
    public AuthResult login(String rawEmail, String password) {
        String email = normalize(rawEmail);
        attempts.checkAllowed(email);
        AppUser user = repo.findByEmail(email).orElse(null);
        boolean ok;
        if (user != null) {
            ok = encoder.matches(password, user.getPassword());
        } else {
            encoder.matches(password, dummyHash());   // result ignored: only spends the same time as a real check
            ok = false;
        }
        if (!ok) {
            attempts.loginFailed(email);
            throw new InvalidCredentialsException();
        }
        attempts.loginSucceeded(email);
        if (!user.isActive()) {
            // only told after the right password, so it does not reveal which e-mails have accounts
            throw new ForbiddenException(DISABLED);
        }
        if (user.isTwoFactorEnabled()) {
            // right password, but the account also wants the e-mailed code: no tokens yet (REQ-AUTH-16)
            throw new TwoFactorRequiredException(twoFactor.begin(user));
        }
        return result(user, refreshTokens.issue(user));
    }

    /** Second step of a two-factor sign-in: the e-mailed code finishes it. */
    public AuthResult verifyLogin(String challengeToken, String code) {
        AppUser user = twoFactor.verify(challengeToken, code);
        if (!user.isActive()) {
            throw new ForbiddenException(DISABLED);
        }
        attempts.loginSucceeded(user.getEmail());
        return result(user, refreshTokens.issue(user));
    }

    /** New access token + the next refresh token (the used one is spent). 401 for anything not valid. */
    public AuthResult refresh(String refreshToken) {
        RefreshTokenService.Rotated rotated = refreshTokens.rotate(refreshToken);
        if (!rotated.user().isActive()) {
            throw new InvalidRefreshTokenException();
        }
        return result(rotated.user(), rotated.refreshToken());
    }

    /** Ends the session on the server: the refresh token (and its whole family) stops working. */
    public void logout(String refreshToken) {
        refreshTokens.revoke(refreshToken);
    }

    private AuthResult result(AppUser user, String refreshToken) {
        return new AuthResult(jwt.generate(user.getEmail()), user.getEmail(), user.getRole(), refreshToken,
                jwt.expiresInSeconds(), user.isGuest());
    }

    private String dummyHash() {
        if (dummyHash == null) {
            dummyHash = encoder.encode("timing-equalizer-" + System.nanoTime());
        }
        return dummyHash;
    }

    private static String normalize(String email) {
        return email == null ? null : email.trim().toLowerCase();
    }

    /**
     * token = access token (JWT, short); refreshToken = opaque, long; expiresIn = access token lifetime in seconds;
     * guest = a guest checkout account (no password of its own yet).
     */
    public record AuthResult(String token, String email, String role, String refreshToken, long expiresIn, boolean guest) {
    }
}