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
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);
|
|
}
|
|
}
|
|
|