forked from baskbuddy/zhangshun
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
170 lines
7.2 KiB
170 lines
7.2 KiB
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):手机号验证码登录,并编排刷新与登出。
|
|
*
|
|
* <p>登录成功后创建服务端登录会话;Access/Refresh 由各自的密钥签发,
|
|
* 数据库只保存 Refresh Token 的 HMAC 摘要。</p>
|
|
*/
|
|
@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<String, Object> 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<User> 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<AccountDeletion> 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<String, Object> 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<String, Object> getCurrentUser(Long userId) {
|
|
User user = userRepository.findById(userId)
|
|
.orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND));
|
|
|
|
Map<String, Object> 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);
|
|
}
|
|
}
|
|
|