UserController.java

package com.saltynote.service.controller;

import com.saltynote.service.domain.VaultType;
import com.saltynote.service.domain.transfer.JwtUser;
import com.saltynote.service.domain.transfer.PasswordReset;
import com.saltynote.service.domain.transfer.PasswordUpdate;
import com.saltynote.service.domain.transfer.Payload;
import com.saltynote.service.domain.transfer.ServiceResponse;
import com.saltynote.service.domain.transfer.TokenPair;
import com.saltynote.service.domain.transfer.UserCredential;
import com.saltynote.service.domain.transfer.UserNewRequest;
import com.saltynote.service.entity.SiteUser;
import com.saltynote.service.entity.Vault;
import com.saltynote.service.event.EmailEvent;
import com.saltynote.service.exception.WebAppRuntimeException;
import com.saltynote.service.security.JWTAuthenticationService;
import com.saltynote.service.service.JwtService;
import com.saltynote.service.service.UserService;
import com.saltynote.service.service.VaultService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import lombok.val;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.PathVariable;
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.RequestMethod;
import org.springframework.web.bind.annotation.RestController;

import java.util.Objects;
import java.util.Optional;

@Tag(name = "UserController", description = "User related APIs")
@RestController
@Slf4j
@RequiredArgsConstructor
public class UserController {

    @Value("${password.minimal.length}")
    private int passwordMinimalLength;

    private final UserService userService;

    private final BCryptPasswordEncoder bCryptPasswordEncoder;

    private final JwtService jwtService;

    private final ApplicationEventPublisher eventPublisher;

    private final VaultService vaultService;

    private final JWTAuthenticationService authenticationService;

    @PostMapping("/email/verification")
    public ResponseEntity<ServiceResponse> getVerificationToken(@Valid @RequestBody Payload payload) {
        // check whether this email is already signed up or not.
        if (userService.getByEmail(payload.getEmail()).isPresent()) {
            return ResponseEntity.status(HttpStatus.BAD_REQUEST)
                .body(new ServiceResponse(HttpStatus.BAD_REQUEST, "Email is already signed up."));
        }

        SiteUser user = new SiteUser().setEmail(payload.getEmail()).setUsername("there");
        eventPublisher.publishEvent(new EmailEvent(this, user, EmailEvent.Type.NEW_USER));
        return ResponseEntity.ok(ServiceResponse.ok("A verification code for signup is sent to you email now"));
    }

    @Operation(summary = "User Signup", description = "Verification token is needed for signup.")
    @PostMapping("/signup")
    public ResponseEntity<JwtUser> signup(@Valid @RequestBody UserNewRequest userNewRequest) {
        if (userNewRequest.getPassword().length() < passwordMinimalLength) {
            throw new WebAppRuntimeException(HttpStatus.BAD_REQUEST,
                    "Password should be at least " + passwordMinimalLength + " characters.");
        }
        // Check token
        Optional<Vault> vaultOp = vaultService.getByEmailAndSecretAndType(userNewRequest.getEmail(),
                userNewRequest.getToken(), VaultType.NEW_ACCOUNT);

        if (vaultOp.isEmpty()) {
            throw new WebAppRuntimeException(HttpStatus.FORBIDDEN, "A valid verification code is required for signup.");
        }
        SiteUser user = userNewRequest.toSiteUser();
        user.setPassword(bCryptPasswordEncoder.encode(user.getPassword()));
        user = userService.create(user);
        if (user.getId() != null && !user.getId().isBlank()) {
            vaultService.deleteById(vaultOp.get().getId());
            return ResponseEntity.ok(new JwtUser(user.getId(), user.getUsername()));
        }
        else {
            throw new WebAppRuntimeException(HttpStatus.INTERNAL_SERVER_ERROR,
                    "Failed to signup, please try again later.");
        }
    }

    @PostMapping("/login")
    public ResponseEntity<TokenPair> authenticate(@RequestBody UserCredential credential, HttpServletRequest request) {
        return ResponseEntity.ok(authenticationService.authenticate(credential, request));
    }

    @PostMapping("/refresh_token")
    public ResponseEntity<TokenPair> refreshToken(@Valid @RequestBody TokenPair tokenPair) {
        // 1. No expiry, and valid.
        JwtUser user = jwtService.parseRefreshToken(tokenPair.getRefreshToken());
        // 2. Not deleted from database.
        Optional<Vault> token = vaultService.findByUserIdAndTypeAndValue(user.getId(), VaultType.REFRESH_TOKEN,
                tokenPair.getRefreshToken());
        if (token.isPresent()) {
            String newToken = jwtService.createAccessToken(user);
            return ResponseEntity.ok(new TokenPair(newToken, tokenPair.getRefreshToken()));
        }
        else {
            throw new WebAppRuntimeException(HttpStatus.BAD_REQUEST, "Invalid refresh token provided!");
        }
    }

    @Transactional
    @DeleteMapping("/refresh_tokens")
    public ResponseEntity<ServiceResponse> cleanRefreshTokens(Authentication auth) {
        JwtUser user = (JwtUser) auth.getPrincipal();
        log.info("[cleanRefreshTokens] user = {}", user);
        vaultService.cleanRefreshTokenByUserId(user.getId());
        return ResponseEntity.ok(ServiceResponse.ok("All your refresh tokens are cleaned."));
    }

    @PostMapping("/password/forget")
    public ResponseEntity<ServiceResponse> forgetPassword(@Valid @RequestBody Payload payload) {
        Optional<SiteUser> usero = userService.getByEmail(payload.getEmail());
        if (usero.isEmpty()) {
            log.warn("User is not found for email = {}", payload.getEmail());
            return ResponseEntity.status(HttpStatus.PRECONDITION_FAILED)
                .body(new ServiceResponse(HttpStatus.PRECONDITION_FAILED, "Invalid email"));
        }

        eventPublisher.publishEvent(new EmailEvent(this, usero.get(), EmailEvent.Type.PASSWORD_FORGET));
        return ResponseEntity.ok(ServiceResponse
            .ok("Password reset email will be sent to your email, please reset your email with link there."));
    }

    @PostMapping("/password/reset")
    public ResponseEntity<ServiceResponse> resetPassword(@Valid @RequestBody PasswordReset passwordReset) {
        val wre = new WebAppRuntimeException(HttpStatus.BAD_REQUEST, "Invalid payload provided.");
        if (passwordReset.getPassword().length() < passwordMinimalLength) {
            throw new WebAppRuntimeException(HttpStatus.BAD_REQUEST,
                    "Password should be at least " + passwordMinimalLength + " characters.");
        }
        Optional<Vault> vo = vaultService.findByToken(passwordReset.getToken());
        if (vo.isEmpty()) {
            throw wre;
        }

        Optional<SiteUser> usero = userService.getById(vo.get().getUserId());
        if (usero.isPresent()) {
            SiteUser user = usero.get();
            user.setPassword(bCryptPasswordEncoder.encode(passwordReset.getPassword()));
            userService.update(user);
            vaultService.deleteById(vo.get().getId());
            return ResponseEntity.ok(ServiceResponse.ok("Password has been reset!"));
        }
        else {
            throw wre;
        }
    }

    @RequestMapping(value = "/password", method = { RequestMethod.POST, RequestMethod.PUT })
    public ResponseEntity<ServiceResponse> updatePassword(@Valid @RequestBody PasswordUpdate passwordUpdate,
            Authentication auth) {
        JwtUser jwtUser = (JwtUser) auth.getPrincipal();
        // Validate new password
        if (passwordUpdate.getPassword().length() < passwordMinimalLength) {
            throw new WebAppRuntimeException(HttpStatus.BAD_REQUEST,
                    "New password should be at least " + passwordMinimalLength + " characters.");
        }

        // Validate old password
        Optional<SiteUser> usero = userService.getById(jwtUser.getId());
        if (usero.isEmpty()) {
            throw new WebAppRuntimeException(HttpStatus.BAD_REQUEST,
                    "Something goes wrong when fetching your info, please try later again.");
        }
        SiteUser user = usero.get();
        if (!bCryptPasswordEncoder.matches(passwordUpdate.getOldPassword(), user.getPassword())) {
            throw new WebAppRuntimeException(HttpStatus.BAD_REQUEST, "Wrong current password is provided.");
        }

        user.setPassword(bCryptPasswordEncoder.encode(passwordUpdate.getPassword()));
        userService.update(user);
        return ResponseEntity.ok(ServiceResponse.ok("Password is updated now."));
    }

    @DeleteMapping("/account/{id}")
    public ResponseEntity<ServiceResponse> accountDeletion(@PathVariable("id") String userId, Authentication auth) {
        JwtUser jwtUser = (JwtUser) auth.getPrincipal();
        if (!Objects.equals(userId, jwtUser.getId())) {
            throw new WebAppRuntimeException(HttpStatus.BAD_REQUEST, "User information is not confirmed");
        }
        userService.cleanupByUserId(jwtUser.getId());
        return ResponseEntity.ok(ServiceResponse.ok("Account deletion is successful."));
    }

}