AuthService.java

package com.hwhub.backend.application.service;

import com.hwhub.backend.config.EmailVerificationProperties;
import com.hwhub.backend.domain.enums.AuthProvider;
import com.hwhub.backend.domain.enums.ProgramType;
import com.hwhub.backend.domain.model.UserEmailVerificationModel;
import com.hwhub.backend.domain.model.UserModel;
import com.hwhub.backend.domain.notification.VerificationMailSender;
import com.hwhub.backend.domain.repository.UserEmailVerificationRepository;
import com.hwhub.backend.domain.repository.UserRepository;
import com.hwhub.backend.presentation.rest.auth.dto.LoginRequest;
import com.hwhub.backend.presentation.rest.common.EmailAlreadyUsedException;
import com.hwhub.backend.presentation.rest.common.EmailAlreadyVerifiedException;
import com.hwhub.backend.presentation.rest.common.EmailNotVerifiedException;
import com.hwhub.backend.presentation.rest.common.EmailVerificationCooldownException;
import com.hwhub.backend.presentation.rest.common.EmailVerificationTokenInvalidException;
import com.hwhub.backend.presentation.rest.common.EmailVerificationTooManyRequestsException;
import com.hwhub.backend.presentation.rest.common.InvalidRefreshTokenException;
import com.hwhub.backend.presentation.rest.common.PasswordLoginNotAllowedException;
import com.hwhub.backend.security.JwtProvider;
import com.hwhub.backend.tool.VerificationTokenGenerator;
import java.time.LocalDateTime;
import java.util.Objects;
import java.util.Optional;
import lombok.RequiredArgsConstructor;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
@RequiredArgsConstructor
public class AuthService {

  private final UserRepository userRepository;
  private final PasswordEncoder passwordEncoder;
  private final JwtProvider jwtProvider;
  private final UserIconService userIconService;
  private final EmailVerificationProperties emailVerificationProperties;
  private final UserEmailVerificationRepository userEmailVerificationRepository;
  private final VerificationMailSender verificationMailSender;

  private static final long USER_ID_ADMIN = 1;

  public LoginInfo login(LoginRequest request) {
    UserModel user =
        userRepository
            .findByEmail(request.getEmail())
            .orElseThrow(() -> new IllegalArgumentException("Invalid email or password"));

    if (!passwordEncoder.matches(request.getPassword(), user.getPasswordHash())) {
      throw new BadCredentialsException("Invalid password");
    }

    if (!user.isActive()) {
      throw new BadCredentialsException("Account is deactivated");
    }

    // Google連携済みなら「パスワードログイン禁止」
    if (AuthProvider.GOOGLE.getCode().equals(user.getAuthProvider())
        || user.getPasswordHash() == null) {
      throw new PasswordLoginNotAllowedException();
    }

    if (emailVerificationProperties.enabled() && user.getEmailVerifiedAt() == null) {
      throw new EmailNotVerifiedException();
    }

    // 画像表示用のURLを生成し設定
    user.setIconUrl(userIconService.getIconUrl(user.getProfileImageKey()));

    String token = jwtProvider.generateToken(user.getUserId(), user.getDisplayName());
    String refreshToken = jwtProvider.generateRefreshToken(user.getUserId());
    return new LoginInfo(token, refreshToken, user);
  }

  public RegisterInfo register(UserModel model) {

    // emailの重複チェック
    // 既に存在しかつactiveな場合はエラー、非アクティブな場合は再有効化して更新
    Optional<UserModel> existing = userRepository.findByEmail(model.getEmail());

    UserModel targetUser;

    if (existing.isPresent()) {
      UserModel found = existing.get();
      if (!emailVerificationProperties.enabled() && found.isActive()) {
        throw new EmailAlreadyUsedException(model.getEmail());
      }
      // 再有効化
      // パスワードをハッシュ化
      String hash = passwordEncoder.encode(model.getPassword());
      found.setPasswordHash(hash);
      found.changeProfile(model.getDisplayName(), model.getLocale());
      found.activate();

      userRepository.updateForReactivation(
          found, found.getUserId(), ProgramType.ONL_AUTH.getCode());
      targetUser = found;

    } else {
      // 新規登録
      // パスワードをハッシュ化
      String hash = passwordEncoder.encode(model.getPassword());
      model.setPasswordHash(hash);

      targetUser = userRepository.insert(model, USER_ID_ADMIN, ProgramType.ONL_AUTH.getCode());
    }

    // 画像表示用のURLを生成し設定
    targetUser.setIconUrl(userIconService.getIconUrl(targetUser.getProfileImageKey()));

    if (!emailVerificationProperties.enabled()) {
      userRepository.markEmailVerified(
          targetUser.getUserId(),
          LocalDateTime.now(),
          USER_ID_ADMIN,
          ProgramType.ONL_AUTH.getCode());

      String token = jwtProvider.generateToken(targetUser.getUserId(), targetUser.getDisplayName());
      String refreshToken = jwtProvider.generateRefreshToken(targetUser.getUserId());
      return RegisterInfo.loggedIn(token, refreshToken, targetUser);
    }

    // prod: verification 発行
    LocalDateTime expiresAt = issueVerificationFor(targetUser);
    return RegisterInfo.verificationRequired(targetUser, expiresAt);
  }

  @Transactional
  public void verifyEmail(String token) {

    LocalDateTime now = LocalDateTime.now();
    byte[] tokenHash = UserEmailVerificationModel.hashToken(token);

    // token_hash で「未使用&期限内」を検索
    var model =
        userEmailVerificationRepository
            .findUsableByTokenHash(tokenHash, now)
            .orElseThrow(EmailVerificationTokenInvalidException::new);

    userEmailVerificationRepository.markUsed(
        model.getUserEmailVerificationId(), now, USER_ID_ADMIN, ProgramType.ONL_AUTH.getCode());

    // ユーザを認証済みに更新
    userRepository.markEmailVerified(
        model.getUserId(), now, USER_ID_ADMIN, ProgramType.ONL_AUTH.getCode());
  }

  @Transactional
  public void resendVerification(String email) {

    // 存在しないメールでも同じ応答にしたいので Optional のまま進める
    Optional<UserModel> opt = userRepository.findByEmail(email);
    if (opt.isEmpty()) {
      return; // 何もせず成功扱い
    }

    UserModel user = opt.get();

    // すでに認証済みなら 409
    if (user.getEmailVerifiedAt() != null) {
      throw new EmailAlreadyVerifiedException();
    }

    // resend制御 + token発行 +(send-mail=trueなら)送信
    issueVerificationFor(user);
  }

  private LocalDateTime issueVerificationFor(UserModel user) {

    LocalDateTime now = LocalDateTime.now();

    // resend 制御
    enforceResendPolicy(user.getUserId(), now);

    String token = VerificationTokenGenerator.generateToken();

    UserEmailVerificationModel model =
        UserEmailVerificationModel.create(
            user.getUserId(), token, now, emailVerificationProperties.tokenTtlMinutes());
    userEmailVerificationRepository.insert(model, USER_ID_ADMIN, ProgramType.ONL_AUTH.getCode());

    // メール送信は設定で制御
    if (emailVerificationProperties.sendMail()) {
      String verifyUrl = buildVerifyUrl(token);
      verificationMailSender.sendVerificationMail(
          Objects.requireNonNull(user.getEmail()),
          user.getDisplayName(),
          Objects.requireNonNull(verifyUrl),
          user.getLocale());
    }

    return model.getExpiresAt();
  }

  private void enforceResendPolicy(long userId, LocalDateTime now) {

    userEmailVerificationRepository
        .findLatestRequestedAt(userId)
        .ifPresent(
            latest -> {
              if (latest.isAfter(
                  now.minusSeconds(emailVerificationProperties.resendCooldownSeconds()))) {
                throw new EmailVerificationCooldownException();
              }
            });

    int count = userEmailVerificationRepository.countRequestedSince(userId, now.minusDays(1));
    if (count >= emailVerificationProperties.maxRequestsPerDay()) {
      throw new EmailVerificationTooManyRequestsException();
    }
  }

  public LoginInfo refresh(String refreshToken) {
    if (!jwtProvider.validateRefreshToken(refreshToken)) {
      throw new InvalidRefreshTokenException();
    }
    Long userId = jwtProvider.getUserIdFromToken(refreshToken);
    UserModel user = userRepository.findById(userId).orElseThrow(InvalidRefreshTokenException::new);
    user.setIconUrl(userIconService.getIconUrl(user.getProfileImageKey()));

    String newAccessToken = jwtProvider.generateToken(userId, user.getDisplayName());
    String newRefreshToken = jwtProvider.generateRefreshToken(userId);
    return new LoginInfo(newAccessToken, newRefreshToken, user);
  }

  private String buildVerifyUrl(String token) {
    return emailVerificationProperties.frontBaseUrl()
        + emailVerificationProperties.verifyPath()
        + "?token="
        + token;
  }

  public record LoginInfo(String token, String refreshToken, UserModel user) {}

  public record RegisterInfo(
      boolean emailVerificationRequired,
      String token,
      String refreshToken,
      UserModel user,
      LocalDateTime verificationExpiresAt) {

    public static RegisterInfo loggedIn(String token, String refreshToken, UserModel user) {
      return new RegisterInfo(false, token, refreshToken, user, null);
    }

    public static RegisterInfo verificationRequired(UserModel user, LocalDateTime expiresAt) {
      return new RegisterInfo(true, null, null, user, expiresAt);
    }
  }
}