feat(phase3): 收口父子单与全链路验收

This commit is contained in:
2026-08-10 12:23:43 +08:00
parent b294695169
commit 75d5fd4e26
55 changed files with 1110 additions and 115 deletions
@@ -13,7 +13,7 @@ import org.springframework.stereotype.Component;
import java.util.Objects;
/**
* 授权快照加载器,调用 IAM 服务并校验返回身份上下文,为认证令牌组装权限声明。
* 授权快照加载器,调用 IAM 服务并校验返回身份上下文,为 Redis 会话组装授权快照。
*/
@Component
public class AuthorizationLoader {
@@ -32,7 +32,7 @@ import java.util.UUID;
import java.time.Duration;
/**
* 统一认证应用服务,编排密码校验、IAM 权限快照加载、JWT 签发、刷新令牌轮换与注销事务。
* 统一认证应用服务,编排密码校验、IAM 权限快照加载、小 JWT 签发、刷新令牌轮换与注销事务。
*/
@Service
public class AuthService {
@@ -117,8 +117,6 @@ public class AuthService {
.claim("membership_id", account.getDefaultMembershipId())
.claim("app_code", account.getDefaultAppCode())
.claim("client_type", authorization.getClientType())
.claim("roles", authorization.getRoles())
.claim("permissions", authorization.getPermissions())
.claim("token_version", account.getTokenVersion())
.claim("password_change_required", Boolean.TRUE.equals(account.getPasswordChangeRequired()))
.build();
@@ -128,7 +126,7 @@ public class AuthService {
byte[] random = new byte[48];
secureRandom.nextBytes(random);
String rawRefresh = Base64.getUrlEncoder().withoutPadding().encodeToString(random);
tokenSessionStore.store(jwtId, account.getId(), rawRefresh,
tokenSessionStore.store(jwtId, account.getId(), authorization.getRoles(), authorization.getPermissions(), rawRefresh,
properties.accessTokenTtl(), properties.refreshTokenTtl());
return new TokenResponse("Bearer", accessToken, properties.accessTokenTtl().toSeconds(),
rawRefresh, properties.refreshTokenTtl().toSeconds());
@@ -174,13 +172,13 @@ public class AuthService {
.claim("user_id", account.getId()).claim("username", account.getUsername())
.claim("tenant_id", account.getDefaultTenantId()).claim("operator_tenant_id", account.getOperatorTenantId())
.claim("membership_id", account.getDefaultMembershipId()).claim("app_code", "MERCHANT")
.claim("client_type", authorization.getClientType()).claim("roles", authorization.getRoles())
.claim("permissions", authorization.getPermissions()).claim("token_version", account.getTokenVersion())
.claim("client_type", authorization.getClientType()).claim("token_version", account.getTokenVersion())
.claim("impersonated", true).claim("impersonation_id", ticket.ticketId())
.claim("actor_user_id", ticket.actorUserId()).claim("actor_membership_id", ticket.actorMembershipId())
.claim("password_change_required", Boolean.TRUE.equals(account.getPasswordChangeRequired())).build();
String accessToken = jwtEncoder.encode(JwtEncoderParameters.from(JwsHeader.with(MacAlgorithm.HS256).build(), claims)).getTokenValue();
tokenSessionStore.storeAccessOnly(jwtId, account.getId(), Duration.ofMinutes(15));
tokenSessionStore.storeAccessOnly(jwtId, account.getId(), authorization.getRoles(),
authorization.getPermissions(), Duration.ofMinutes(15));
return new ImpersonationTokenResponse("Bearer", accessToken, 900);
}
@@ -41,13 +41,15 @@ public class TokenSessionStore {
keyPrefix = RedisTokenKeys.normalize(keyPrefix);
}
public void store(String jwtId, Long userId, String rawRefreshToken,
Duration accessTtl, Duration refreshTtl) {
public void store(String jwtId, Long userId, Set<String> roles, Set<String> permissions,
String rawRefreshToken, Duration accessTtl, Duration refreshTtl) {
String accessKey = RedisTokenKeys.access(keyPrefix, jwtId);
String refreshKey = RedisTokenKeys.refresh(keyPrefix, sha256(rawRefreshToken));
String userKey = userSessionsKey(userId);
try {
redisTemplate.opsForValue().set(accessKey, userId.toString(), accessTtl);
String accessValue = objectMapper.writeValueAsString(
new AccessSession(1, userId, normalized(roles), normalized(permissions)));
redisTemplate.opsForValue().set(accessKey, accessValue, accessTtl);
String refreshValue = objectMapper.writeValueAsString(new RefreshSession(userId, jwtId));
redisTemplate.opsForValue().set(refreshKey, refreshValue, refreshTtl);
SetOperations<String, String> sets = redisTemplate.opsForSet();
@@ -59,10 +61,13 @@ public class TokenSessionStore {
}
}
public void storeAccessOnly(String jwtId, Long userId, Duration ttl) {
public void storeAccessOnly(String jwtId, Long userId, Set<String> roles,
Set<String> permissions, Duration ttl) {
try {
String accessKey = RedisTokenKeys.access(keyPrefix, jwtId);
redisTemplate.opsForValue().set(accessKey, userId.toString(), ttl);
String accessValue = objectMapper.writeValueAsString(
new AccessSession(1, userId, normalized(roles), normalized(permissions)));
redisTemplate.opsForValue().set(accessKey, accessValue, ttl);
redisTemplate.opsForSet().add(userSessionsKey(userId), accessKey);
redisTemplate.expire(userSessionsKey(userId), ttl);
} catch (Exception exception) {
@@ -134,7 +139,7 @@ public class TokenSessionStore {
while (cursor.hasNext()) {
String key = cursor.next();
String value = redisTemplate.opsForValue().get(key);
if ((!refresh && userId.toString().equals(value)) || (refresh && belongsTo(value, userId))) {
if ((!refresh && accessBelongsTo(value, userId)) || (refresh && belongsTo(value, userId))) {
result.add(key);
}
}
@@ -149,6 +154,16 @@ public class TokenSessionStore {
catch (Exception ignored) { return false; }
}
private boolean accessBelongsTo(String value, Long userId) {
if (value == null || value.isBlank()) return false;
try { return userId.equals(objectMapper.readValue(value, AccessSession.class).userId()); }
catch (Exception ignored) { return false; }
}
private static Set<String> normalized(Set<String> values) {
return values == null ? Set.of() : Set.copyOf(values);
}
private String userSessionsKey(Long userId) {
return keyPrefix + ":user-sessions:" + userId;
}
@@ -168,6 +183,12 @@ public class TokenSessionStore {
}
public record RefreshSession(Long userId, String accessJwtId) { }
public record AccessSession(int version, Long userId, Set<String> roles, Set<String> permissions) {
public AccessSession {
roles = normalized(roles);
permissions = normalized(permissions);
}
}
public record ImpersonationTicket(String ticketId, Long targetAuthUserId, Long tenantId,
Long membershipId, Long actorUserId, Long actorMembershipId,
String reason) { }