AuthController.java

package com.rbctcsworld.ecommerce.auth;

import jakarta.validation.Valid;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;

/**
 * POST /api/auth/register -> 201 {token,email,role,refreshToken,expiresIn} | 400 validation | 409 duplicate email
 * POST /api/auth/login    -> 200 {token,email,role,refreshToken,expiresIn} | 400 | 401 bad credentials | 429 locked
 * POST /api/auth/refresh  -> 200 {token,email,role,refreshToken,expiresIn} | 400 | 401 invalid/expired/reused
 * POST /api/auth/logout   -> 204 (always, also for an unknown token)
 * POST /api/auth/login/verify {challengeToken, code} -> 200 {token,...} | 400 wrong/expired code
 *   (login answers 200 {twoFactorRequired:true, challengeToken} instead when the account has two-factor sign-in on)
 * POST /api/auth/forgot-password {email}             -> 202 (always the same answer) | 400 validation
 * POST /api/auth/reset-password  {token, newPassword} -> 204 | 400 invalid/used/expired link or weak password
 */
@RestController
@RequestMapping("/api/auth")
public class AuthController {

    record AuthRequest(@Email @NotBlank String email, @NotBlank @Size(min = 8, max = 72) String password) {
    }

    record GuestRequest(@Email @NotBlank @Size(max = 255) String email) {
    }

    record RefreshRequest(@NotBlank @Size(max = 200) String refreshToken) {
    }

    record LogoutRequest(@Size(max = 200) String refreshToken) {
    }

    record ForgotPasswordRequest(@Email @NotBlank @Size(max = 255) String email) {
    }

    record ResetPasswordRequest(@NotBlank @Size(max = 200) String token,
                                @NotBlank @Size(min = 8, max = 72) String newPassword) {
    }

    /** The answer to every forgot-password request, whether or not the e-mail has an account. */
    record Accepted(String message) {
        static final Accepted RESET_REQUESTED = new Accepted(
                "If an account exists for this e-mail, we have sent a link to reset the password.");
    }

    private final AuthService service;
    private final PasswordResetService passwordReset;

    public AuthController(AuthService service, PasswordResetService passwordReset) {
        this.service = service;
        this.passwordReset = passwordReset;
    }

    @PostMapping("/register")
    @ResponseStatus(HttpStatus.CREATED)
    AuthService.AuthResult register(@Valid @RequestBody AuthRequest r) {
        return service.register(r.email(), r.password());
    }

    /** Guest checkout: 201 {token, ..., guest: true} | 400 bad e-mail | 409 the e-mail already has an account. */
    @PostMapping("/guest")
    @ResponseStatus(HttpStatus.CREATED)
    AuthService.AuthResult guest(@Valid @RequestBody GuestRequest r) {
        return service.guest(r.email());
    }

    record VerifyRequest(@NotBlank @Size(max = 200) String challengeToken,
                         @NotBlank @jakarta.validation.constraints.Pattern(regexp = "^\\s*\\d{6}\\s*$", message = "must be 6 digits") String code) {
    }

    @PostMapping("/login/verify")
    AuthService.AuthResult verify(@Valid @RequestBody VerifyRequest r) {
        return service.verifyLogin(r.challengeToken(), r.code());
    }

    /** The password was right and the account wants the e-mailed code: 200 with the challenge, no tokens. */
    @org.springframework.web.bind.annotation.ExceptionHandler(TwoFactorRequiredException.class)
    TwoFactorService.Challenge twoFactorRequired(TwoFactorRequiredException e) {
        return e.getChallenge();
    }

    @PostMapping("/login")
    AuthService.AuthResult login(@Valid @RequestBody AuthRequest r) {
        return service.login(r.email(), r.password());
    }

    @PostMapping("/refresh")
    AuthService.AuthResult refresh(@Valid @RequestBody RefreshRequest r) {
        return service.refresh(r.refreshToken());
    }

    @PostMapping("/logout")
    @ResponseStatus(HttpStatus.NO_CONTENT)
    void logout(@Valid @RequestBody LogoutRequest r) {
        service.logout(r.refreshToken());
    }

    @PostMapping("/forgot-password")
    @ResponseStatus(HttpStatus.ACCEPTED)
    Accepted forgotPassword(@Valid @RequestBody ForgotPasswordRequest r) {
        passwordReset.requestReset(r.email());
        return Accepted.RESET_REQUESTED;
    }

    @PostMapping("/reset-password")
    @ResponseStatus(HttpStatus.NO_CONTENT)
    void resetPassword(@Valid @RequestBody ResetPasswordRequest r) {
        passwordReset.resetPassword(r.token(), r.newPassword());
    }
}