PasswordResetService.java

package com.hwhub.backend.application.service;

import com.hwhub.backend.config.PasswordResetProperties;
import com.hwhub.backend.domain.enums.ProgramType;
import com.hwhub.backend.domain.model.UserModel;
import com.hwhub.backend.domain.model.UserPasswordResetModel;
import com.hwhub.backend.domain.notification.PasswordResetMailSender;
import com.hwhub.backend.domain.repository.UserPasswordResetRepository;
import com.hwhub.backend.domain.repository.UserRepository;
import com.hwhub.backend.presentation.rest.common.PasswordResetCooldownException;
import com.hwhub.backend.presentation.rest.common.PasswordResetDisabledException;
import com.hwhub.backend.presentation.rest.common.PasswordResetLimitExceededException;
import com.hwhub.backend.presentation.rest.common.PasswordResetTokenExpiredException;
import com.hwhub.backend.presentation.rest.common.PasswordResetTokenInvalidException;
import com.hwhub.backend.tool.VerificationTokenGenerator;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.Objects;
import java.util.Optional;
import lombok.RequiredArgsConstructor;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
@RequiredArgsConstructor
public class PasswordResetService {

  private static final long USER_ID_ADMIN = 1;

  private final PasswordResetProperties properties;
  private final UserRepository userRepository;
  private final UserPasswordResetRepository userPasswordResetRepository;
  private final PasswordEncoder passwordEncoder;

  private final PasswordResetMailSender mailerSender;

  /** パスワードリセット要求(常に204で返すことを前提に内部で処理する) */
  @Transactional
  public void requestReset(String email) {
    if (!properties.enabled()) {
      // 無効化中はException
      throw new PasswordResetDisabledException();
    }

    Optional<UserModel> userOpt = userRepository.findByEmail(email);
    if (userOpt.isEmpty()) {
      // 情報漏えい防止:存在しないメールでも成功扱い
      return;
    }

    UserModel user = userOpt.get();
    if (!user.isActive()) {
      // 情報漏えい防止:非アクティブでも成功扱い
      return;
    }

    LocalDateTime now = LocalDateTime.now();

    enforceCooldown(user.getUserId(), now);
    enforceDailyLimit(user.getUserId(), now.toLocalDate());

    String token = VerificationTokenGenerator.generateToken();
    UserPasswordResetModel model =
        UserPasswordResetModel.create(user.getUserId(), token, now, properties.tokenTtlMinutes());

    userPasswordResetRepository.insert(model, USER_ID_ADMIN, ProgramType.ONL_AUTH.getCode());

    if (properties.sendMail()) {
      String url = buildResetUrl(token);
      mailerSender.sendPasswordResetMail(
          Objects.requireNonNull(user.getEmail()),
          user.getDisplayName(),
          Objects.requireNonNull(url),
          user.getLocale());
    }
  }

  /** パスワードリセット確定 */
  @Transactional
  public void confirmReset(String token, String newPassword) {
    if (!properties.enabled()) {
      throw new PasswordResetTokenInvalidException();
    }

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

    // token を検索(未使用 & 期限内)
    UserPasswordResetModel reset =
        userPasswordResetRepository
            .findUsableByTokenHash(tokenHash, now)
            .orElseThrow(PasswordResetTokenInvalidException::new);

    if (reset.getExpiresAt().isBefore(now)) {
      throw new PasswordResetTokenExpiredException();
    }

    // パスワード更新
    UserModel user =
        userRepository
            .findById(reset.getUserId())
            .orElseThrow(PasswordResetTokenInvalidException::new);
    String hash = passwordEncoder.encode(newPassword);
    user.changePasswordHash(hash, now);
    userRepository.updatePassword(user, USER_ID_ADMIN, ProgramType.ONL_PWDRST.getCode());

    // token を使用済みに更新
    int updated =
        userPasswordResetRepository.markUsedIfUnused(
            reset.getUserPasswordResetId(), now, USER_ID_ADMIN, ProgramType.ONL_AUTH.getCode());

    // ここで0件なら二重実行/競合、invalid 扱い
    if (updated == 0) {
      throw new PasswordResetTokenInvalidException();
    }
  }

  private void enforceCooldown(Long userId, LocalDateTime now) {
    userPasswordResetRepository
        .findLatestRequestedAt(userId)
        .ifPresent(
            latest -> {
              if (latest.isAfter(now.minusSeconds(properties.resendCooldownSeconds()))) {
                throw new PasswordResetCooldownException();
              }
            });
  }

  private void enforceDailyLimit(Long userId, LocalDate today) {
    LocalDateTime start = today.atStartOfDay();
    LocalDateTime end = today.plusDays(1).atStartOfDay();

    int count = userPasswordResetRepository.countRequestedOnDate(userId, start, end);
    if (count >= properties.maxRequestsPerDay()) {
      throw new PasswordResetLimitExceededException();
    }
  }

  /**
   * パスワードリセット用のURLを作成する。 ex) https://stg.familyapp-hwhub.com/password/reset?token=xxx
   *
   * @param token トークン
   * @return
   */
  private String buildResetUrl(String token) {
    return properties.frontBaseUrl() + properties.resetPath() + "?token=" + token;
  }
}