package com.mydreamjob.auth; import com.mydreamjob.audit.AuditContext; import com.mydreamjob.audit.AuditService; import lombok.extern.slf4j.Slf4j; import org.springframework.dao.DataAccessException; import org.springframework.stereotype.Service; import org.springframework.transaction.PlatformTransactionManager; import org.springframework.transaction.TransactionDefinition; import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.support.TransactionTemplate; import java.time.Instant; import java.time.LocalDateTime; import java.time.ZoneId; import java.util.Objects; import java.util.Optional; import java.util.UUID; /** * S02 登录会话服务:创建会话、校验会话、轮换与消费 Refresh Token、撤销会话。 * *

并发正确性完全由数据库条件更新保证({@link AuthTokenRepository#consumeIfCurrent}), * 不使用任何 JVM 本地锁。

* *

时间一律由调用方(认证入口)传入的 {@code capturedNow} 决定,本类不自行取时。

*/ @Slf4j @Service public class AuthSessionService { /** 固定业务时区(设计 D13)。 */ public static final ZoneId BUSINESS_ZONE = ZoneId.of("Asia/Shanghai"); /** 撤销原因白名单(设计 D12 / 第十二节)。 */ public static final String REASON_LOGOUT = "USER_LOGOUT"; public static final String REASON_REPLAY = "REFRESH_REUSE_DETECTED"; public static final String REASON_ACCOUNT_DELETED = "ACCOUNT_DELETED"; public static final String REASON_USER_DISABLED = "USER_DISABLED"; private final AuthTokenRepository authTokenRepository; private final AuthTokenHasher authTokenHasher; private final JwtTokenProvider jwtTokenProvider; private final UserRepository userRepository; private final AuditService auditService; /** 刷新尝试:消费旧 Token + 写入后继 Token 必须在同一事务内完成。 */ private final TransactionTemplate refreshTransaction; /** 重放撤销:必须在刷新尝试事务**结束之后**独立提交,避免与并发胜出方互相持锁。 */ private final TransactionTemplate replayTransaction; public AuthSessionService(AuthTokenRepository authTokenRepository, AuthTokenHasher authTokenHasher, JwtTokenProvider jwtTokenProvider, UserRepository userRepository, AuditService auditService, PlatformTransactionManager transactionManager) { this.authTokenRepository = authTokenRepository; this.authTokenHasher = authTokenHasher; this.jwtTokenProvider = jwtTokenProvider; this.userRepository = userRepository; this.auditService = auditService; this.refreshTransaction = new TransactionTemplate(transactionManager); this.refreshTransaction.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRED); this.replayTransaction = new TransactionTemplate(transactionManager); this.replayTransaction.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW); } /** 刷新尝试的结果:成功(携带新 Token 对)或识别为重放。 */ private sealed interface RefreshAttempt { record Succeeded(SessionTokens tokens) implements RefreshAttempt { } record Replayed(Long userId, String sessionId) implements RefreshAttempt { } } /** 一次会话签发的结果。Token 原文只在内存中短暂存在。 */ public record SessionTokens(String accessToken, String refreshToken, String sessionId) { } // ── 登录:创建会话 ─────────────────────────────────────────── /** * 创建一次登录会话并签发 Access/Refresh Token。 * *

必须运行在调用方事务内:会话记录持久化失败时,整个登录事务失败,不得返回 Token。

*/ @Transactional public SessionTokens createSession(User user, Instant capturedNow, AuditContext ctx) { // 失败关闭:不给不可用用户创建活动会话,更不返回 Token。 // 不能先发 Token 再由过滤器拒绝 —— 那样 Token 已经流出去了。 if (user == null || !isUsable(user)) { throw new InvalidTokenException(); } String sessionId = UUID.randomUUID().toString(); String accessJti = UUID.randomUUID().toString(); String refreshJti = UUID.randomUUID().toString(); IssuedToken access = jwtTokenProvider.generateAccessToken( user.getId(), user.getRole().name(), sessionId, accessJti, capturedNow); IssuedToken refresh = jwtTokenProvider.generateRefreshToken( user.getId(), sessionId, refreshJti, capturedNow); authTokenRepository.save(AuthToken.builder() .userId(user.getId()) .tokenHash(authTokenHasher.hash(refresh.token())) .tokenType(AuthToken.TYPE_REFRESH) .expiredAt(toLocal(refresh.expiresAt())) .revoked(false) .sessionId(sessionId) .tokenJti(refreshJti) // created_at 必须与入口捕获的 capturedNow 同源,不能由审计读取系统时间。 .createdAt(toLocal(capturedNow)) .build()); auditService.logSessionCreated(user.getId(), sessionRef(sessionId), ctx, toLocal(capturedNow)); return new SessionTokens(access.token(), refresh.token(), sessionId); } // ── 校验:Access 所属会话是否可用(失败关闭)────────────────── /** * Access Token 的服务端会话校验(设计 D10)。任一条不满足即返回 {@code false}: * 会话属于该用户、存在未消费未撤销未过期的 Refresh 记录、用户存在且 active。 * *

关于账号注销:这里刻意只检查 {@code active},不单独判定注销记录的 * COMPLETED 状态。账号注销完成时 {@code AccountDeletionCompletionWorker} 会 * 1) 脱敏并将用户置为 inactive,2) 撤销该用户全部会话 —— 两道处理叠加后, * 真实注销用户的旧 Token 无论走哪条路径都会失败。把 COMPLETED 的 409/9303 语义 * 保留给 {@code DeletionGuardInterceptor},可避免改变既有注销端点契约。

* *

任何数据库/运行时异常一律**失败关闭**,不建立认证。

*/ public boolean isSessionActive(Long userId, String sessionId, Instant capturedNow) { if (userId == null || sessionId == null || sessionId.isBlank()) { return false; } try { if (!isUserUsable(userId)) { // 固定安全文本:不包含 userId、sessionId、Token 或 claim。 log.warn("session validation rejected: user not usable"); return false; } if (authTokenRepository.countActiveSessionTokens(userId, sessionId, toLocal(capturedNow)) <= 0) { log.warn("session validation rejected: no active session"); return false; } return true; } catch (Exception e) { // 固定安全文本:不回显异常消息、userId、sessionId 或 Token。 log.warn("session validation failed"); return false; } } // ── 刷新:消费并轮换 ───────────────────────────────────────── /** * 刷新编排入口。**本方法不运行在事务中** —— 这一点是并发重放处置正确性的前提: * 刷新尝试事务必须先结束(释放其持有的行锁),重放撤销才能在无竞争的事务里生效, * 从而覆盖并发胜出方刚写入的后继 Token。 * * @throws InvalidTokenException 任何失败,包括重放 */ public SessionTokens refresh(String rawRefreshToken, Instant capturedNow, AuditContext ctx) { // 校验与摘要不访问数据库,放在事务外,异常时仍能拿到 sessionId 用于重放撤销。 RefreshTokenClaims claims = jwtTokenProvider.parseAndValidateRefreshToken(rawRefreshToken, capturedNow); String hash = authTokenHasher.hash(rawRefreshToken); RefreshAttempt attempt; try { attempt = refreshTransaction.execute( status -> executeRefreshAttempt(claims, hash, capturedNow, ctx)); } catch (DataAccessException e) { // 并发的另一次刷新正持有该记录的行锁。本次刷新无法完成, // 按重放处置并撤销整个会话(失败关闭),对调用方仍是统一的 401。 revokeReplayedSession(claims.userId(), claims.sessionId(), ctx, capturedNow); throw new InvalidTokenException(); } if (attempt instanceof RefreshAttempt.Replayed replayed) { // 此时刷新尝试事务已经结束:胜出方要么已提交、要么已被本事务回滚释放, // 撤销事务不会再与之互相持锁。 revokeReplayedSession(replayed.userId(), replayed.sessionId(), ctx, capturedNow); throw new InvalidTokenException(); } return ((RefreshAttempt.Succeeded) attempt).tokens(); } /** * 一次刷新尝试:校验记录、原子消费旧 Token、写入后继 Token,全部在同一事务内。 * *

识别为重放时**不抛异常**,而是返回 {@link RefreshAttempt.Replayed} —— * 否则事务回滚会连带丢失撤销所需的会话信息。

*/ private RefreshAttempt executeRefreshAttempt(RefreshTokenClaims claims, String hash, Instant capturedNow, AuditContext ctx) { AuthToken record = authTokenRepository.findTopByTokenHashOrderByIdDesc(hash) .orElseThrow(InvalidTokenException::new); // 声明必须与数据库记录一致,否则视为不可信。 if (!Objects.equals(record.getUserId(), claims.userId()) || !Objects.equals(record.getSessionId(), claims.sessionId()) || !Objects.equals(record.getTokenJti(), claims.tokenId())) { throw new InvalidTokenException(); } User user = requireUsableUser(claims.userId()); int consumed = authTokenRepository.consumeIfCurrent( record.getId(), record.getVersion(), toLocal(capturedNow), toLocal(capturedNow)); if (consumed != 1) { // 已消费 / 已撤销 / 已过期:交回编排入口做重放处置。 return new RefreshAttempt.Replayed(record.getUserId(), record.getSessionId()); } String newAccessJti = UUID.randomUUID().toString(); String newRefreshJti = UUID.randomUUID().toString(); IssuedToken access = jwtTokenProvider.generateAccessToken( user.getId(), user.getRole().name(), claims.sessionId(), newAccessJti, capturedNow); IssuedToken refresh = jwtTokenProvider.generateRefreshToken( user.getId(), claims.sessionId(), newRefreshJti, capturedNow); authTokenRepository.save(AuthToken.builder() .userId(user.getId()) .tokenHash(authTokenHasher.hash(refresh.token())) .tokenType(AuthToken.TYPE_REFRESH) .expiredAt(toLocal(refresh.expiresAt())) .revoked(false) .sessionId(claims.sessionId()) .tokenJti(newRefreshJti) .parentTokenId(record.getId()) // created_at 与入口捕获的 capturedNow 同源。 .createdAt(toLocal(capturedNow)) .build()); auditService.logTokenRefreshed(user.getId(), sessionRef(claims.sessionId()), ctx, toLocal(capturedNow)); return new RefreshAttempt.Succeeded( new SessionTokens(access.token(), refresh.token(), claims.sessionId())); } /** * 重放处置:在独立 REQUIRES_NEW 事务中撤销该会话全部记录并写脱敏审计, * 随后**校验该会话已无活动 Token**(含并发胜出方的后继 Token)。 * *

撤销失败不做吞掉处理:先做有限次重试(无 sleep),仍失败则让异常向外传播 —— * 宁可暴露基础设施故障,也不能留下一个可继续使用的会话。

*/ private void revokeReplayedSession(Long userId, String sessionId, AuditContext ctx, Instant capturedNow) { if (sessionId == null) { return; } for (int attempt = 0; attempt < MAX_REPLAY_REVOKE_ATTEMPTS; attempt++) { replayTransaction.execute(status -> { authTokenRepository.revokeSession(userId, sessionId, toLocal(capturedNow), REASON_REPLAY); auditService.logRefreshReuseDetected(userId, sessionRef(sessionId), ctx, toLocal(capturedNow)); auditService.logSessionRevoked(userId, sessionRef(sessionId), REASON_REPLAY, ctx, toLocal(capturedNow)); return null; }); if (authTokenRepository.countActiveSessionTokens(userId, sessionId, toLocal(capturedNow)) <= 0) { return; } // 胜出方的后继 Token 可能在本轮撤销之后才提交,重试一轮覆盖它。 // 固定安全文本:不记录 userId、sessionId 或 Token。 log.warn("replayed session still had active tokens; retrying revocation"); } throw new IllegalStateException("replayed session could not be revoked"); } /** 重放撤销的重试上限(无 sleep;正常情况下第一轮即成功)。 */ private static final int MAX_REPLAY_REVOKE_ATTEMPTS = 3; // ── 登出:撤销当前会话 ─────────────────────────────────────── /** * 撤销当前会话(幂等)。重复登出不报错,也不影响其他设备会话。 */ @Transactional public void logout(Long userId, String sessionId, Instant capturedNow, AuditContext ctx) { if (userId == null || sessionId == null || sessionId.isBlank()) { throw new InvalidTokenException(); } authTokenRepository.revokeSession(userId, sessionId, toLocal(capturedNow), REASON_LOGOUT); auditService.logSessionRevoked(userId, sessionRef(sessionId), REASON_LOGOUT, ctx, toLocal(capturedNow)); } // ── 全局吊销 ───────────────────────────────────────────────── /** * 撤销该用户全部会话(账号注销完成、用户禁用等)。 * * @return 受影响行数 */ @Transactional public int revokeAllUserSessions(Long userId, String reason, Instant capturedNow) { if (userId == null) { return 0; } return authTokenRepository.revokeAllForUser(userId, toLocal(capturedNow), reason); } // ── 内部工具 ───────────────────────────────────────────────── private User requireUsableUser(Long userId) { return userRepository.findById(userId) .filter(this::isUsable) .orElseThrow(InvalidTokenException::new); } private boolean isUserUsable(Long userId) { Optional user = userRepository.findById(userId); return user.isPresent() && isUsable(user.get()); } private boolean isUsable(User user) { return user.getActive() != null && user.getActive(); } /** * 审计用的会话标识:sessionId 的 HMAC 截断,不可逆且不泄露原值。 */ String sessionRef(String sessionId) { return authTokenHasher.hash("sid:" + sessionId).substring(0, 16); } static LocalDateTime toLocal(Instant instant) { return LocalDateTime.ofInstant(instant, BUSINESS_ZONE); } }