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.
 
 
 

332 lines
16 KiB

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、撤销会话。
*
* <p>并发正确性完全由数据库条件更新保证({@link AuthTokenRepository#consumeIfCurrent}),
* 不使用任何 JVM 本地锁。</p>
*
* <p>时间一律由调用方(认证入口)传入的 {@code capturedNow} 决定,本类不自行取时。</p>
*/
@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。
*
* <p>必须运行在调用方事务内:会话记录持久化失败时,整个登录事务失败,不得返回 Token。</p>
*/
@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。
*
* <p><strong>关于账号注销</strong>:这里刻意只检查 {@code active},不单独判定注销记录的
* COMPLETED 状态。账号注销完成时 {@code AccountDeletionCompletionWorker} 会
* 1) 脱敏并将用户置为 inactive,2) 撤销该用户全部会话 —— 两道处理叠加后,
* 真实注销用户的旧 Token 无论走哪条路径都会失败。把 COMPLETED 的 409/9303 语义
* 保留给 {@code DeletionGuardInterceptor},可避免改变既有注销端点契约。</p>
*
* <p>任何数据库/运行时异常一律**失败关闭**,不建立认证。</p>
*/
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,全部在同一事务内。
*
* <p>识别为重放时**不抛异常**,而是返回 {@link RefreshAttempt.Replayed} ——
* 否则事务回滚会连带丢失撤销所需的会话信息。</p>
*/
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)。
*
* <p>撤销失败不做吞掉处理:先做有限次重试(无 sleep),仍失败则让异常向外传播 ——
* 宁可暴露基础设施故障,也不能留下一个可继续使用的会话。</p>
*/
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> 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);
}
}