package com.mydreamjob.auth; import com.mydreamjob.audit.AuditContext; import com.mydreamjob.audit.AuditService; import com.mydreamjob.common.exception.BusinessException; import com.mydreamjob.common.exception.ErrorCode; import com.mydreamjob.deletion.AccountDeletion; import com.mydreamjob.deletion.AccountDeletionPhoneHasher; import com.mydreamjob.deletion.AccountDeletionRepository; import com.mydreamjob.deletion.DeletionStatus; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.time.Clock; import java.time.Instant; import java.util.*; /** * 认证服务(S02):手机号验证码登录,并编排刷新与登出。 * *

登录成功后创建服务端登录会话;Access/Refresh 由各自的密钥签发, * 数据库只保存 Refresh Token 的 HMAC 摘要。

*/ @Slf4j @Service @RequiredArgsConstructor public class AuthService { /** 登录与刷新响应中的 expiresIn 秒数,保持既有契约不变(设计 D04)。 */ public static final long EXPIRES_IN_SECONDS = 86400L; private final UserRepository userRepository; private final SmsVerificationService smsVerificationService; private final AuditService auditService; private final AccountDeletionRepository accountDeletionRepository; private final AccountDeletionPhoneHasher accountDeletionPhoneHasher; private final AuthSessionService authSessionService; private final Clock clock; private final com.mydreamjob.auth.password.PasswordSetupService passwordSetupService; /** * Mock 登录:验证短信验证码后自动创建/登录用户,并建立一次登录会话。 */ @Transactional public Map mockLogin(String phone, String code, String purpose, String requestId, String ipAddress, String userAgent) { // 验证验证码(传入 purpose,确保验证码用途匹配) smsVerificationService.verifyCode(phone, code, purpose); // 原手机号禁止重新注册:已注销账号的手机号墓碑(HMAC 摘要)命中则拒绝, // 且不得自动创建新用户(不把原手机号明文写入注销表或审计)。 String phoneHash = accountDeletionPhoneHasher.hash(phone); if (accountDeletionRepository.findByDeletedPhoneHash(phoneHash).isPresent()) { throw new BusinessException(ErrorCode.ACCOUNT_DELETED); } // 查找或创建用户 Optional userOpt = userRepository.findByPhone(phone); User user; if (userOpt.isEmpty()) { user = User.builder() .phone(phone) .nickname("用户_" + phone.substring(7)) .role(User.Role.UNASSIGNED) .roles(Set.of()) .active(true) .verified(true) .build(); user = userRepository.save(user); // 日志不得记录完整手机号 log.info("Auto-created user: id={}", user.getId()); } else { user = userOpt.get(); } // 已注销(COMPLETED)用户禁止登录 Optional deletion = accountDeletionRepository.findByUserId(user.getId()); if (deletion.isPresent() && deletion.get().getStatus() == DeletionStatus.COMPLETED) { throw new BusinessException(ErrorCode.ACCOUNT_DELETED); } // 认证入口只取一次时间,后续 JWT iat/exp、数据库时间与审计全部同源。 Instant capturedNow = clock.instant(); AuditContext ctx = AuditContext.builder() .requestId(requestId) .ipAddress(ipAddress) .userAgent(userAgent) .build(); // 会话记录必须与登录在同一事务内持久化;失败则整个登录失败,不返回 Token。 AuthSessionService.SessionTokens tokens = authSessionService.createSession(user, capturedNow, ctx); auditService.log(user.getId(), "LOGIN", "USER", user.getId(), "User logged in", ctx, toLocal(capturedNow)); Map result = new HashMap<>(); result.put("userId", user.getId()); result.put("phone", user.getPhone()); result.put("nickname", user.getNickname()); result.put("role", user.getRole()); result.put("identitySelectionRequired", user.getRole() == User.Role.UNASSIGNED); result.put("passwordConfigured", user.getPasswordHash() != null); if ("login".equals(purpose) && user.getPasswordHash() == null) { String setupProof = passwordSetupService.issue(user, tokens.sessionId(), capturedNow); if (setupProof != null) { result.put("passwordSetupToken", setupProof); result.put("passwordSetupExpiresIn", 600); } } result.put("accessToken", tokens.accessToken()); result.put("refreshToken", tokens.refreshToken()); result.put("expiresIn", EXPIRES_IN_SECONDS); return result; } /** * 刷新:消费并轮换 Refresh Token。 * * @throws InvalidTokenException 任何失败(统一对外 401) */ public AuthSessionService.SessionTokens refresh(String refreshToken, String requestId, String ipAddress, String userAgent) { Instant capturedNow = clock.instant(); return authSessionService.refresh(refreshToken, capturedNow, context(requestId, ipAddress, userAgent)); } /** * 登出:撤销当前会话(幂等)。 */ public void logout(Long userId, String sessionId, String requestId, String ipAddress, String userAgent) { Instant capturedNow = clock.instant(); authSessionService.logout(userId, sessionId, capturedNow, context(requestId, ipAddress, userAgent)); } /** * 获取当前用户信息 */ public Map getCurrentUser(Long userId) { User user = userRepository.findById(userId) .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); Map result = new HashMap<>(); result.put("id", user.getId()); result.put("phone", user.getPhone()); result.put("email", user.getEmail()); result.put("nickname", user.getNickname()); result.put("avatarUrl", user.getAvatarUrl()); result.put("role", user.getRole()); result.put("identitySelectionRequired", user.getRole() == User.Role.UNASSIGNED); result.put("active", user.getActive()); result.put("passwordConfigured", user.getPasswordHash() != null); result.put("verified", user.getVerified()); result.put("createdAt", user.getCreatedAt()); return result; } private AuditContext context(String requestId, String ipAddress, String userAgent) { return AuditContext.builder() .requestId(requestId) .ipAddress(ipAddress) .userAgent(userAgent) .build(); } private static java.time.LocalDateTime toLocal(Instant instant) { return AuthSessionService.toLocal(instant); } }