优化
This commit is contained in:
@@ -4,6 +4,7 @@
|
||||
|
||||
- 负责登录、注册、账号凭据、Access/Refresh Token、退出、密码和账号归属范围。
|
||||
- 客户主档、成员、角色权限归 `iam/`;Auth 通过 IAM 客户端获取授权快照,不跨库写 IAM。
|
||||
- 修改国内/海外 Token、Audience、JWKS 或跨区域身份边界前,先读 `../../docs/跨区域认证与服务通信契约.md`。
|
||||
- `tenant_id=0` 为平台作用域,`tenant_id>0` 为客户 ID;不得引入租户管理或切换。
|
||||
|
||||
## 模块入口
|
||||
|
||||
@@ -11,6 +11,10 @@ public record TokenResponse(
|
||||
@Schema(description = "JWT access token / JWT 访问令牌", format = "password") String accessToken,
|
||||
@Schema(description = "Access token lifetime in seconds / 访问令牌有效秒数", example = "1800") long expiresIn,
|
||||
@Schema(description = "Refresh token / 刷新令牌", format = "password") String refreshToken,
|
||||
@Schema(description = "Refresh token lifetime in seconds / 刷新令牌有效秒数", example = "604800") long refreshExpiresIn
|
||||
@Schema(description = "Refresh token lifetime in seconds / 刷新令牌有效秒数", example = "604800") long refreshExpiresIn,
|
||||
@Schema(description = "Short-lived overseas API access token; null only when overseas JWT is disabled / 海外 API 短时访问令牌;仅海外 JWT 未启用时为空",
|
||||
format = "password", nullable = true) String overseasAccessToken,
|
||||
@Schema(description = "Overseas access token lifetime in seconds; zero only when overseas JWT is disabled / 海外访问令牌有效秒数;仅海外 JWT 未启用时为 0",
|
||||
example = "900", minimum = "0") long overseasExpiresIn
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ import org.springframework.security.oauth2.jwt.JwtEncoderParameters;
|
||||
import org.springframework.security.oauth2.jwt.JwsHeader;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
|
||||
import java.security.SecureRandom;
|
||||
import java.time.Instant;
|
||||
@@ -49,6 +50,8 @@ public class AuthService {
|
||||
private AuthorizationLoader authorizationLoader;
|
||||
@Resource
|
||||
private TokenSessionStore tokenSessionStore;
|
||||
@Resource
|
||||
private ObjectProvider<OverseasTokenService> overseasTokens;
|
||||
|
||||
private final SecureRandom secureRandom = new SecureRandom();
|
||||
|
||||
@@ -134,8 +137,13 @@ public class AuthService {
|
||||
String rawRefresh = Base64.getUrlEncoder().withoutPadding().encodeToString(random);
|
||||
tokenSessionStore.store(jwtId, account.getId(), authorization.getRoles(), authorization.getPermissions(), rawRefresh,
|
||||
properties.accessTokenTtl(), properties.refreshTokenTtl());
|
||||
OverseasTokenService service = overseasTokens.getIfAvailable();
|
||||
OverseasTokenService.OverseasTokenResponse overseas = service == null
|
||||
? null : service.issue(account, authorization);
|
||||
return new TokenResponse("Bearer", accessToken, properties.accessTokenTtl().toSeconds(),
|
||||
rawRefresh, properties.refreshTokenTtl().toSeconds());
|
||||
rawRefresh, properties.refreshTokenTtl().toSeconds(),
|
||||
overseas == null ? null : overseas.accessToken(),
|
||||
overseas == null ? 0L : overseas.expiresIn());
|
||||
}
|
||||
|
||||
@Transactional
|
||||
|
||||
@@ -36,16 +36,22 @@ public class OverseasTokenService {
|
||||
throw new BizException(ResultCode.UNAUTHORIZED, "账号不可用");
|
||||
}
|
||||
AuthorizationSnapshot authorization = authorizationLoader.load(account);
|
||||
return issue(account, authorization);
|
||||
}
|
||||
|
||||
public OverseasTokenResponse issue(AuthAccount account, AuthorizationSnapshot authorization) {
|
||||
Instant issuedAt = Instant.now();
|
||||
Instant expiresAt = issuedAt.plus(properties.accessTokenTtl());
|
||||
JwtClaimsSet claims = JwtClaimsSet.builder()
|
||||
.issuer(properties.issuer()).audience(List.of(properties.audience()))
|
||||
.subject(account.getId().toString()).id(UUID.randomUUID().toString())
|
||||
.issuedAt(issuedAt).expiresAt(expiresAt)
|
||||
.claim("userId", account.getId()).claim("tenantId", account.getDefaultTenantId())
|
||||
.claim("appCode", account.getDefaultAppCode()).claim("roles", authorization.getRoles())
|
||||
.claim("user_id", account.getId()).claim("tenant_id", account.getDefaultTenantId())
|
||||
.claim("membership_id", authorization.getMembershipId())
|
||||
.claim("app_code", account.getDefaultAppCode()).claim("client_type", authorization.getClientType())
|
||||
.claim("roles", authorization.getRoles())
|
||||
.claim("authorities", authorization.getPermissions())
|
||||
.claim("permissionVersion", authorization.getPermissionVersion() == null ? 0L : authorization.getPermissionVersion())
|
||||
.claim("permission_version", authorization.getPermissionVersion() == null ? 0L : authorization.getPermissionVersion())
|
||||
.build();
|
||||
String token = encoder.encode(JwtEncoderParameters.from(
|
||||
JwsHeader.with(SignatureAlgorithm.RS256).keyId(properties.currentKid()).build(), claims)).getTokenValue();
|
||||
|
||||
@@ -34,6 +34,7 @@ class AuthServiceSmallJwtTest {
|
||||
@MockBean private JwtEncoder jwtEncoder;
|
||||
@MockBean private AuthorizationLoader authorizationLoader;
|
||||
@MockBean private TokenSessionStore tokenSessionStore;
|
||||
@MockBean private OverseasTokenService overseasTokenService;
|
||||
@Resource private AuthService service;
|
||||
|
||||
@Configuration(proxyBeanMethods = false)
|
||||
@@ -59,7 +60,7 @@ class AuthServiceSmallJwtTest {
|
||||
account.setDefaultAppCode("PLATFORM");
|
||||
account.setTokenVersion(1);
|
||||
AuthorizationSnapshot snapshot = new AuthorizationSnapshot(42L, 0L, 84L, "PLATFORM",
|
||||
"WEB", Set.of("PLATFORM_ADMIN"), Set.of("platform:order:list"));
|
||||
"WEB", Set.of("PLATFORM_ADMIN"), Set.of("platform:order:list"), 1L);
|
||||
Jwt encoded = org.mockito.Mockito.mock(Jwt.class);
|
||||
|
||||
when(repository.findByUsername("operator")).thenReturn(account);
|
||||
@@ -67,8 +68,10 @@ class AuthServiceSmallJwtTest {
|
||||
when(authorizationLoader.load(account)).thenReturn(snapshot);
|
||||
when(jwtEncoder.encode(any())).thenReturn(encoded);
|
||||
when(encoded.getTokenValue()).thenReturn("small-token");
|
||||
when(overseasTokenService.issue(account, snapshot)).thenReturn(
|
||||
new OverseasTokenService.OverseasTokenResponse("Bearer", "overseas-token", 900));
|
||||
|
||||
service.login(new LoginRequest("operator", "secret"));
|
||||
var response = service.login(new LoginRequest("operator", "secret"));
|
||||
|
||||
ArgumentCaptor<JwtEncoderParameters> jwt = ArgumentCaptor.forClass(JwtEncoderParameters.class);
|
||||
verify(jwtEncoder).encode(jwt.capture());
|
||||
@@ -88,6 +91,42 @@ class AuthServiceSmallJwtTest {
|
||||
org.mockito.ArgumentMatchers.eq(Set.of("platform:order:list")), any(),
|
||||
org.mockito.ArgumentMatchers.eq(Duration.ofMinutes(30)),
|
||||
org.mockito.ArgumentMatchers.eq(Duration.ofDays(7)));
|
||||
verify(overseasTokenService).issue(account, snapshot);
|
||||
assertThat(response.overseasAccessToken()).isEqualTo("overseas-token");
|
||||
assertThat(response.overseasExpiresIn()).isEqualTo(900);
|
||||
}
|
||||
|
||||
@Test
|
||||
void refreshRotatesDomesticAndOverseasTokensFromOneAuthorizationSnapshot() {
|
||||
AuthAccount account = new AuthAccount();
|
||||
account.setId(52L);
|
||||
account.setUsername("merchant");
|
||||
account.setAccountStatus("ACTIVE");
|
||||
account.setDefaultTenantId(10001L);
|
||||
account.setOperatorTenantId(10001L);
|
||||
account.setDefaultMembershipId(94L);
|
||||
account.setDefaultAppCode("MERCHANT");
|
||||
account.setTokenVersion(3);
|
||||
AuthorizationSnapshot snapshot = new AuthorizationSnapshot(52L, 10001L, 94L, "MERCHANT",
|
||||
"WEB", Set.of("MERCHANT_ADMIN"), Set.of("merchant:order:list"), 3L);
|
||||
Jwt encoded = org.mockito.Mockito.mock(Jwt.class);
|
||||
|
||||
when(tokenSessionStore.consume("refresh-old")).thenReturn(
|
||||
new TokenSessionStore.RefreshSession(52L, "refresh-session"));
|
||||
when(repository.findById(52L)).thenReturn(account);
|
||||
when(authorizationLoader.load(account)).thenReturn(snapshot);
|
||||
when(jwtEncoder.encode(any())).thenReturn(encoded);
|
||||
when(encoded.getTokenValue()).thenReturn("domestic-refreshed");
|
||||
when(overseasTokenService.issue(account, snapshot)).thenReturn(
|
||||
new OverseasTokenService.OverseasTokenResponse("Bearer", "overseas-refreshed", 900));
|
||||
|
||||
var response = service.refresh("refresh-old");
|
||||
|
||||
assertThat(response.accessToken()).isEqualTo("domestic-refreshed");
|
||||
assertThat(response.overseasAccessToken()).isEqualTo("overseas-refreshed");
|
||||
assertThat(response.overseasExpiresIn()).isEqualTo(900);
|
||||
verify(authorizationLoader).load(account);
|
||||
verify(overseasTokenService).issue(account, snapshot);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -102,7 +141,7 @@ class AuthServiceSmallJwtTest {
|
||||
account.setDefaultAppCode("MERCHANT");
|
||||
account.setTokenVersion(2);
|
||||
AuthorizationSnapshot snapshot = new AuthorizationSnapshot(43L, 10001L, 85L, "MERCHANT",
|
||||
"WEB", Set.of("MERCHANT_OWNER"), Set.of("merchant:order:list"));
|
||||
"WEB", Set.of("MERCHANT_OWNER"), Set.of("merchant:order:list"), 2L);
|
||||
Jwt encoded = org.mockito.Mockito.mock(Jwt.class);
|
||||
TokenSessionStore.ImpersonationTicket ticket = new TokenSessionStore.ImpersonationTicket(
|
||||
"ticket-1", 43L, 10001L, 85L, 42L, 84L, "support");
|
||||
|
||||
@@ -64,11 +64,13 @@ class OverseasTokenServiceTest {
|
||||
assertThat(parameters.getJwsHeader().getKeyId()).isEqualTo("current-kid");
|
||||
assertThat(parameters.getClaims().getAudience()).containsExactly("lastleg-api");
|
||||
assertThat(parameters.getClaims().getClaims())
|
||||
.containsEntry("userId", 42L).containsEntry("tenantId", 100L)
|
||||
.containsEntry("appCode", "MERCHANT").containsEntry("permissionVersion", 7L)
|
||||
.containsEntry("user_id", 42L).containsEntry("tenant_id", 100L)
|
||||
.containsEntry("membership_id", 84L)
|
||||
.containsEntry("app_code", "MERCHANT").containsEntry("client_type", "WEB")
|
||||
.containsEntry("permission_version", 7L)
|
||||
.containsEntry("roles", Set.of("MERCHANT_ADMIN"))
|
||||
.containsEntry("authorities", Set.of("merchant:order:list"))
|
||||
.doesNotContainKeys("user_id", "tenant_id", "app_code");
|
||||
.doesNotContainKeys("userId", "tenantId", "appCode", "permissionVersion");
|
||||
assertThat(response.expiresIn()).isEqualTo(900);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user