This commit is contained in:
2026-09-29 17:55:54 +08:00
parent 41d8387cd5
commit 54ad26c607
111 changed files with 1837 additions and 962 deletions
+4 -4
View File
@@ -5,7 +5,7 @@
- Mapper 只由 `BaiyueAuthApplication.java` 对 `com.baiyue.auth` 根包按 `@Mapper` 过滤扫描;新增或移动 Mapper 后从后端根运行 `node scripts/mapper-scan-contract.mjs`,不得增加子域扫描配置。
- 负责登录、注册、账号凭据、Access/Refresh Token、退出、密码和账号归属范围。
- 客户主档、成员、角色权限归 `iam/`;Auth 通过 IAM 客户端获取授权快照,不跨库写 IAM。
- 修改国内/海外 Token、Audience、JWKS 或跨区域身份边界前,先读 `../../docs/跨区域认证与服务通信契约.md`。
- 修改国内/海外 Token、Audience、JWKS 或跨区域身份边界前,先读 `../../docs/统一认证与服务通信契约.md`。
- `tenant_id=0` 为平台作用域,`tenant_id>0` 为客户 ID;不得引入租户管理或切换。
## 模块入口
@@ -16,8 +16,8 @@
| 内部账号管理与 scope | `controller/InternalAccountController.java`、`service/AccountManagementService.java` |
| 登录标识与企业联系人解耦 | `service/AccountManagementService.java`、`dto/AccountManagementDtos.java`、`service/MerchantRegistrationService.java`;注册手机号只在创建时同时初始化首位管理员登录手机号和公司联系电话,创建后二者独立维护;账号手机号变更需撤销该账号全部会话 |
| 统一 JWT 签发与 JWKS | `config/AuthSecurityConfig.java`、`config/AuthSecurityProperties.java`、`service/AuthService.java`;Auth 是国内与海外仓共用的唯一用户 Token 签发方,JWKS 公钥发布按认证接口契约实现 |
| 国内小 JWT 与会话 | `service/AuthService.java`、`service/TokenSessionStore.java`;浏览器登录 Token 只承载稳定身份、应用域和版本声明,国内服务结合 Redis 会话校验 |
| 海外访问 Token 签发 | `service/OverseasTokenService.java`;登录/刷新编排使用同一用户身份同时签发短时 RS256 海外 Token,限定海外 Audience,只携带稳定身份、客户范围和版本声明,不携带角色或权限集;前端统一认证层负责按区域 API 自动选择 |
| 统一小 JWT 与会话 | `service/AuthService.java`、`service/TokenSessionStore.java`;浏览器登录 Token 只承载稳定身份、应用域和版本声明,各业务服务结合 Redis 会话校验 |
| 统一 RS256 签发 | `config/UnifiedJwtConfiguration.java`、`config/UnifiedJwtProperties.java`;登录、刷新、代登录使用唯一 Access Token,`JWT_RSA_JWK_SET` 和 `JWT_CURRENT_KID` 仅配置在 Auth;不再提供海外 Token 兑换 |
| 海外 PDA 实时会话校验 | `controller/InternalAccountController.java`、`service/OverseasSessionValidationService.java`;只接受海外 Gateway 已验签后提取的 userId/tokenVersion/overseasWarehouseId,以内部服务令牌保护并实时联查 IAM 当前仓绑定 |
| Token 会话与权限快照 | `service/TokenSessionStore.java`、`client/AuthorizationLoader.java`、`client/IamAuthorizationClient.java`;消费端映射见 `../common-security/src/main/java/com/baiyue/common/security/RedisAccessTokenSessionValidator.java`、`JwtClaimsMapper.java` |
| 注册协作 | `service/MerchantRegistrationService.java` |
@@ -28,7 +28,7 @@
## 修改约束
- 密码、Token、敏感 Claim 不得写日志;统一 JWT 使用 RS256,私钥只留在 Auth,JWKS 只发布公钥。国内登录 Token 的实时权限来自 Redis 会话快照;短时海外 Token 不携带角色或权限集,海外授权由 IAM 事件投影到 Lastleg,再由海外 Gateway 验证并以可信内网上下文传递,海外服务不访问国内 Redis。修改登录响应、Claim、Audience、JWKS、权限快照或过期策略时联查 Admin/Client 统一认证层、`common-security/`、`gateway/`、`iam/` 和海外服务消费者。
- 密码、Token、敏感 Claim 不得写日志;统一 JWT 使用 RS256,私钥只留在 Auth,JWKS 只发布公钥。统一 Token 不携带角色或权限集,权限来自共享 Redis 会话;用户、客户、权限和仓绑定变更必须撤销受影响会话。独立海外网关与安全投影仍处于迁移阶段,不能将本地签发改造视为整体已部署。修改登录响应、Claim、Audience、JWKS、权限快照或过期策略时联查 Admin/Client 统一认证层、`common-security/`、`gateway/`、`iam/` 和海外服务消费者。
- 注册跨 Auth/IAM 必须保持幂等和失败补偿;内部接口仅接受服务令牌。
- 登录账号的 username、phone、email 唯一性只约束 Auth 账号;IAM 公司联系电话/联系邮箱是企业资料,不得反向覆盖账号登录标识,也不得因其他企业使用相同联系电话而拒绝。成员登录手机号变更只能由 IAM 账号协作入口同步并撤销该账号全部会话。
- 业务错误保持 HTTP 200 + body code;Security 认证失败保留真实 401/403。
@@ -8,7 +8,7 @@ import java.time.Duration;
* 认证安全配置属性,承载 JWT 密钥、签发方及访问令牌、刷新令牌有效期。
*/
@ConfigurationProperties(prefix = "baiyue.security")
public record AuthSecurityProperties(String jwtSecret, String issuer, String audience,
public record AuthSecurityProperties(String issuer, String audience,
Duration accessTokenTtl, Duration refreshTokenTtl,
int maxLoginFailures, Duration lockDuration) {
}
@@ -1,22 +0,0 @@
package com.baiyue.auth.config;
import com.nimbusds.jose.jwk.source.ImmutableSecret;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
import org.springframework.security.oauth2.jwt.JwtEncoder;
import org.springframework.security.oauth2.jwt.NimbusJwtEncoder;
import java.nio.charset.StandardCharsets;
/** 国内现有 HS256 编码器,始终为认证服务主编码器。 */
@Configuration(proxyBeanMethods = false)
public class DomesticJwtConfiguration {
@Bean("jwtEncoder")
@Primary
JwtEncoder jwtEncoder(AuthSecurityProperties properties) {
byte[] secret = properties.jwtSecret().getBytes(StandardCharsets.UTF_8);
if (secret.length < 32) throw new IllegalStateException("JWT_SECRET 至少需要 32 字节");
return new NimbusJwtEncoder(new ImmutableSecret<>(secret));
}
}
@@ -1,10 +0,0 @@
package com.baiyue.auth.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
import java.time.Duration;
/** 海外尾程 RS256 短时令牌配置,默认关闭。 */
@ConfigurationProperties(prefix = "baiyue.security.overseas-jwt")
public record OverseasJwtProperties(boolean enabled, String issuer, String audience,
String rsaJwkSet, String currentKid, Duration accessTokenTtl) { }
@@ -3,7 +3,6 @@ package com.baiyue.auth.config;
import com.nimbusds.jose.jwk.JWKSet;
import com.nimbusds.jose.jwk.RSAKey;
import com.nimbusds.jose.jwk.source.ImmutableJWKSet;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@@ -11,27 +10,26 @@ import org.springframework.security.oauth2.jwt.JwtEncoder;
import org.springframework.security.oauth2.jwt.NimbusJwtEncoder;
@Configuration(proxyBeanMethods = false)
@EnableConfigurationProperties(OverseasJwtProperties.class)
@ConditionalOnProperty(name = "baiyue.security.overseas-jwt.enabled", havingValue = "true")
public class OverseasJwtConfiguration {
@Bean("overseasJwtEncoder")
JwtEncoder overseasJwtEncoder(OverseasJwtProperties properties) {
@EnableConfigurationProperties(UnifiedJwtProperties.class)
public class UnifiedJwtConfiguration {
@Bean("jwtEncoder")
JwtEncoder jwtEncoder(UnifiedJwtProperties properties) {
try {
JWKSet set = JWKSet.parse(properties.rsaJwkSet());
RSAKey current = set.getKeyByKeyId(properties.currentKid()) instanceof RSAKey rsa ? rsa : null;
if (current == null || !current.isPrivate()) {
throw new IllegalStateException("海外 current-kid 必须指向包含私钥的 RSA 密钥");
throw new IllegalStateException("统一 current-kid 必须指向包含私钥的 RSA 密钥");
}
if (set.getKeys().stream().anyMatch(jwk -> !(jwk instanceof RSAKey))) {
throw new IllegalStateException("海外 JWK Set 只能包含 RSA 密钥");
throw new IllegalStateException("统一 JWK Set 只能包含 RSA 密钥");
}
if (set.getKeys().stream().anyMatch(jwk -> !properties.currentKid().equals(jwk.getKeyID())
&& jwk instanceof RSAKey rsa && rsa.isPrivate())) {
throw new IllegalStateException("海外非当前密钥只能配置公钥");
throw new IllegalStateException("统一非当前密钥只能配置公钥");
}
return new NimbusJwtEncoder(new ImmutableJWKSet<>(set));
} catch (java.text.ParseException exception) {
throw new IllegalStateException("海外 RSA JWK Set 配置无效", exception);
throw new IllegalStateException("统一 RSA JWK Set 配置无效", exception);
}
}
}
@@ -0,0 +1,7 @@
package com.baiyue.auth.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
/** Unified RS256 signing key configuration. Private keys remain in Auth. */
@ConfigurationProperties(prefix = "baiyue.security.jwt")
public record UnifiedJwtProperties(String rsaJwkSet, String currentKid) { }
@@ -23,8 +23,6 @@ import com.baiyue.auth.dto.AccountManagementDtos.ChangePasswordRequest;
import com.baiyue.auth.dto.AccountManagementDtos.ForcedPasswordChangeRequest;
import com.baiyue.auth.dto.AccountManagementDtos;
import com.baiyue.auth.service.AccountManagementService;
import com.baiyue.auth.service.OverseasTokenService;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.security.access.prepost.PreAuthorize;
/**
@@ -40,19 +38,6 @@ public class AuthController {
private MerchantRegistrationService registrationService;
@Resource
private AccountManagementService accountManagementService;
@Resource
private ObjectProvider<OverseasTokenService> overseasTokens;
@PostMapping("/overseas/token")
@PreAuthorize("isAuthenticated()")
@Operation(summary = "兑换海外尾程访问令牌", description = "使用已通过国内会话校验的身份兑换 15 分钟 RS256 海外令牌。")
public ApiResponse<OverseasTokenService.OverseasTokenResponse> exchangeOverseasToken(
@AuthenticationPrincipal AuthenticatedUser user) {
OverseasTokenService service = overseasTokens.getIfAvailable();
if (service == null) throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "海外认证未启用");
return ApiResponse.success(service.exchange(user.userId()));
}
@PutMapping("/password")
@PreAuthorize("isAuthenticated()")
@Operation(summary = "修改当前用户密码")
@@ -1,10 +1,9 @@
package com.baiyue.auth.controller;
import com.baiyue.auth.config.OverseasJwtProperties;
import com.baiyue.auth.config.UnifiedJwtProperties;
import com.nimbusds.jose.jwk.JWKSet;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.security.SecurityRequirements;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.http.CacheControl;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
@@ -13,17 +12,16 @@ import org.springframework.web.bind.annotation.RestController;
import java.util.Map;
import java.util.concurrent.TimeUnit;
/** 仅在海外 JWT 显式启用后发布 RSA 公钥。 */
/** 发布统一 JWT 的 RSA 公钥。 */
@RestController
@ConditionalOnProperty(name = "baiyue.security.overseas-jwt.enabled", havingValue = "true")
public class JwksController {
private final OverseasJwtProperties properties;
private final UnifiedJwtProperties properties;
public JwksController(OverseasJwtProperties properties) { this.properties = properties; }
public JwksController(UnifiedJwtProperties properties) { this.properties = properties; }
@GetMapping("/api/auth/.well-known/jwks.json")
@SecurityRequirements
@Operation(summary = "获取海外 JWT 公钥集", description = "仅发布当前和轮换期上一把 RSA 公钥,不包含私钥。")
@Operation(summary = "获取统一 JWT 公钥集", description = "仅发布当前和轮换期上一把 RSA 公钥,不包含私钥。")
public ResponseEntity<Map<String, Object>> jwks() throws Exception {
return ResponseEntity.ok().cacheControl(CacheControl.maxAge(5, TimeUnit.MINUTES).cachePublic())
.body(JWKSet.parse(properties.rsaJwkSet()).toPublicJWKSet().toJSONObject());
@@ -14,10 +14,6 @@ public record TokenResponse(
@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 = "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,
@Schema(description = "Current role snapshot returned outside JWT / JWT 外返回的当前角色快照") Set<String> roles,
@Schema(description = "Current authority snapshot returned outside JWT / JWT 外返回的当前权限快照") Set<String> authorities
) {
@@ -16,14 +16,14 @@ import com.baiyue.common.core.exception.BizException;
import jakarta.annotation.Resource;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.oauth2.jose.jws.MacAlgorithm;
import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm;
import org.springframework.security.oauth2.jwt.JwtClaimsSet;
import org.springframework.security.oauth2.jwt.JwtEncoder;
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 com.baiyue.auth.config.UnifiedJwtProperties;
import java.security.SecureRandom;
import java.time.Instant;
@@ -51,7 +51,7 @@ public class AuthService {
@Resource
private TokenSessionStore tokenSessionStore;
@Resource
private ObjectProvider<OverseasTokenService> overseasTokens;
private UnifiedJwtProperties signingProperties;
private final SecureRandom secureRandom = new SecureRandom();
@@ -147,13 +147,8 @@ 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(),
overseas == null ? null : overseas.accessToken(),
overseas == null ? 0L : overseas.expiresIn(),
authorization.getRoles(), authorization.getPermissions());
}
@@ -208,17 +203,11 @@ public class AuthService {
String accessToken = jwtEncoder.encode(JwtEncoderParameters.from(signingHeader(), claims)).getTokenValue();
tokenSessionStore.storeAccessOnly(jwtId, account.getId(), authorization.getRoles(),
authorization.getPermissions(), Duration.ofMinutes(15));
OverseasTokenService service = overseasTokens.getIfAvailable();
OverseasTokenService.OverseasTokenResponse overseas = service == null
? null : service.issue(account, authorization);
return new ImpersonationTokenResponse("Bearer", accessToken, 900,
overseas == null ? null : overseas.accessToken(),
overseas == null ? 0L : overseas.expiresIn(),
authorization.getRoles(), authorization.getPermissions());
}
public record ImpersonationTokenResponse(String tokenType, String accessToken, long expiresIn,
String overseasAccessToken, long overseasExpiresIn,
java.util.Set<String> roles,
java.util.Set<String> authorities) {
public ImpersonationTokenResponse {
@@ -228,7 +217,7 @@ public class AuthService {
}
private JwsHeader signingHeader() {
return JwsHeader.with(MacAlgorithm.HS256).build();
return JwsHeader.with(SignatureAlgorithm.RS256).keyId(signingProperties.currentKid()).build();
}
private static BizException invalidCredentials() {
@@ -1,71 +0,0 @@
package com.baiyue.auth.service;
import com.baiyue.auth.client.AuthorizationLoader;
import com.baiyue.auth.client.AuthorizationSnapshot;
import com.baiyue.auth.config.OverseasJwtProperties;
import com.baiyue.auth.entity.AuthAccount;
import com.baiyue.auth.repository.AuthAccountRepository;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import jakarta.annotation.Resource;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm;
import org.springframework.security.oauth2.jwt.JwtClaimsSet;
import org.springframework.security.oauth2.jwt.JwtEncoder;
import org.springframework.security.oauth2.jwt.JwtEncoderParameters;
import org.springframework.security.oauth2.jwt.JwsHeader;
import org.springframework.stereotype.Service;
import java.time.Instant;
import java.util.List;
import java.util.UUID;
/** 将已经通过国内 HMAC 与 Redis 校验的会话兑换为海外 RS256 短时令牌。 */
@Service
@ConditionalOnProperty(name = "baiyue.security.overseas-jwt.enabled", havingValue = "true")
public class OverseasTokenService {
@Resource private AuthAccountRepository accounts;
@Resource private AuthorizationLoader authorizationLoader;
@Resource private OverseasJwtProperties properties;
@Resource @Qualifier("overseasJwtEncoder") private JwtEncoder encoder;
public OverseasTokenResponse exchange(long authenticatedUserId) {
AuthAccount account = accounts.findById(authenticatedUserId);
if (account == null || !"ACTIVE".equals(account.getAccountStatus())) {
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.Builder claimsBuilder = JwtClaimsSet.builder()
.issuer(properties.issuer()).audience(List.of(properties.audience()))
.subject(account.getId().toString()).id(UUID.randomUUID().toString())
.issuedAt(issuedAt).expiresAt(expiresAt)
.claim("user_id", account.getId()).claim("tenant_id", authorization.getTenantId())
.claim("membership_id", authorization.getMembershipId())
.claim("app_code", authorization.getAppCode()).claim("client_type", authorization.getClientType())
.claim("token_version", account.getTokenVersion() == null ? 0 : account.getTokenVersion())
.claim("permission_version", authorization.getPermissionVersion() == null ? 0L : authorization.getPermissionVersion());
if (authorization.getOverseasWarehouseId() != null) {
claimsBuilder.claim("overseas_warehouse_id", authorization.getOverseasWarehouseId());
}
JwtClaimsSet claims = claimsBuilder.build();
String token = encoder.encode(JwtEncoderParameters.from(
JwsHeader.with(SignatureAlgorithm.RS256).keyId(properties.currentKid()).build(), claims)).getTokenValue();
return new OverseasTokenResponse("Bearer", token, properties.accessTokenTtl().toSeconds(),
authorization.getRoles(), authorization.getPermissions());
}
public record OverseasTokenResponse(String tokenType, String accessToken, long expiresIn,
java.util.Set<String> roles, java.util.Set<String> authorities) {
public OverseasTokenResponse {
roles = roles == null ? java.util.Set.of() : java.util.Set.copyOf(roles);
authorities = authorities == null ? java.util.Set.of() : java.util.Set.copyOf(authorities);
}
}
}
+6 -8
View File
@@ -30,7 +30,6 @@ spring:
namespace: ${NACOS_NAMESPACE:public}
encode: UTF-8
baiyue.security:
jwt-secret: ${JWT_SECRET:change-me-to-at-least-32-bytes-long-before-start}
internal-service-token: ${INTERNAL_SERVICE_TOKEN:change-me-to-an-internal-token-at-least-32-bytes-long}
token-key-prefix: ${REDIS_TOKEN_KEY_PREFIX:baiyue:auth}
issuer: ${JWT_ISSUER:auth-service}
@@ -39,13 +38,12 @@ baiyue.security:
refresh-token-ttl: ${REFRESH_TOKEN_TTL:7d}
max-login-failures: ${MAX_LOGIN_FAILURES:5}
lock-duration: ${LOGIN_LOCK_DURATION:15m}
overseas-jwt:
enabled: ${OVERSEAS_JWT_ENABLED:false}
issuer: ${OVERSEAS_JWT_ISSUER:auth-service}
audience: ${OVERSEAS_JWT_AUDIENCE:lastleg-api}
rsa-jwk-set: ${OVERSEAS_JWT_RSA_JWK_SET:}
current-kid: ${OVERSEAS_JWT_CURRENT_KID:}
access-token-ttl: ${OVERSEAS_ACCESS_TOKEN_TTL:15m}
jwt-mode: jwks
session-mode: redis
jwk-set-uri: ${JWT_JWKS_URI:http://auth-service:9100/api/auth/.well-known/jwks.json}
jwt:
rsa-jwk-set: ${JWT_RSA_JWK_SET:}
current-kid: ${JWT_CURRENT_KID:}
baiyue.clients:
iam:
@@ -1,29 +0,0 @@
package com.baiyue.auth.config;
import org.junit.jupiter.api.Test;
import org.springframework.boot.autoconfigure.AutoConfigurations;
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
import org.springframework.security.oauth2.jwt.JwtEncoder;
import static org.assertj.core.api.Assertions.assertThat;
class DualTrackJwtConfigurationTest {
private final ApplicationContextRunner runner = new ApplicationContextRunner()
.withConfiguration(AutoConfigurations.of(DomesticJwtConfiguration.class, OverseasJwtConfiguration.class))
.withBean(AuthSecurityProperties.class, () -> new AuthSecurityProperties(
"domestic-secret-at-least-32-bytes-long", "auth-service", "lastleg-api",
java.time.Duration.ofMinutes(24), java.time.Duration.ofDays(7), 5,
java.time.Duration.ofMinutes(15)))
.withPropertyValues(
"baiyue.security.overseas-jwt.enabled=false");
@Test
void startsDomesticEncoderWithoutRsaConfiguration() {
runner.run(context -> {
assertThat(context).hasNotFailed();
assertThat(context).hasBean("jwtEncoder");
assertThat(context).doesNotHaveBean("overseasJwtEncoder");
assertThat(context).hasSingleBean(JwtEncoder.class);
});
}
}
@@ -0,0 +1,43 @@
package com.baiyue.auth.config;
import com.nimbusds.jose.JWSAlgorithm;
import com.nimbusds.jose.jwk.JWKSet;
import com.nimbusds.jose.jwk.gen.RSAKeyGenerator;
import org.junit.jupiter.api.Test;
import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm;
import org.springframework.security.oauth2.jwt.*;
import java.time.Instant;
import java.util.List;
import static org.assertj.core.api.Assertions.*;
class UnifiedJwtConfigurationTest {
@Test
void signsWithCurrentKeyAndRetainsPreviousPublicKeyForRotation() throws Exception {
var current = new RSAKeyGenerator(2048).keyID("current").algorithm(JWSAlgorithm.RS256).generate();
var previous = new RSAKeyGenerator(2048).keyID("previous").algorithm(JWSAlgorithm.RS256).generate();
var properties = new UnifiedJwtProperties(new JWKSet(List.of(current, previous.toPublicJWK())).toString(false), "current");
var encoder = new UnifiedJwtConfiguration().jwtEncoder(properties);
var now = Instant.now();
var token = encoder.encode(JwtEncoderParameters.from(
JwsHeader.with(SignatureAlgorithm.RS256).keyId("current").build(),
JwtClaimsSet.builder().subject("42").issuedAt(now).expiresAt(now.plusSeconds(60)).build()));
var decoded = NimbusJwtDecoder.withPublicKey(current.toRSAPublicKey()).build().decode(token.getTokenValue());
assertThat(decoded.getSubject()).isEqualTo("42");
assertThat(decoded.getHeaders()).containsEntry("kid", "current").containsEntry("alg", "RS256");
assertThatThrownBy(() -> NimbusJwtDecoder.withPublicKey(previous.toRSAPublicKey()).build().decode(token.getTokenValue()))
.isInstanceOf(JwtException.class);
}
@Test
void rejectsMissingPrivateKeyAndPreviousPrivateKey() throws Exception {
var current = new RSAKeyGenerator(2048).keyID("current").generate();
var previous = new RSAKeyGenerator(2048).keyID("previous").generate();
var config = new UnifiedJwtConfiguration();
assertThatThrownBy(() -> config.jwtEncoder(new UnifiedJwtProperties(new JWKSet(current.toPublicJWK()).toString(), "current")))
.isInstanceOf(IllegalStateException.class);
assertThatThrownBy(() -> config.jwtEncoder(new UnifiedJwtProperties(new JWKSet(List.of(current, previous)).toString(false), "current")))
.isInstanceOf(IllegalStateException.class);
assertThatThrownBy(() -> config.jwtEncoder(new UnifiedJwtProperties("", "current")))
.isInstanceOf(IllegalStateException.class);
}
}
@@ -1,6 +1,6 @@
package com.baiyue.auth.controller;
import com.baiyue.auth.config.OverseasJwtProperties;
import com.baiyue.auth.config.UnifiedJwtProperties;
import com.nimbusds.jose.JWSAlgorithm;
import com.nimbusds.jose.jwk.JWKSet;
import com.nimbusds.jose.jwk.KeyUse;
@@ -20,8 +20,7 @@ class JwksControllerTest {
RSAKey current = key("current", true);
RSAKey previous = key("previous", false);
String json = new JWKSet(List.of(current, previous)).toString(false);
var properties = new OverseasJwtProperties(true, "auth-service", "lastleg-api", json,
"current", Duration.ofMinutes(15));
var properties = new UnifiedJwtProperties(json, "current");
var body = new JwksController(properties).jwks().getBody();
String published = new com.fasterxml.jackson.databind.ObjectMapper().writeValueAsString(body);
@@ -12,7 +12,7 @@ import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.oauth2.jose.jws.MacAlgorithm;
import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.jwt.JwtEncoder;
import org.springframework.security.oauth2.jwt.JwtEncoderParameters;
@@ -37,21 +37,24 @@ 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)
static class TestConfig {
@Bean
com.baiyue.auth.config.UnifiedJwtProperties signingProperties() {
return new com.baiyue.auth.config.UnifiedJwtProperties("unused", "current");
}
@Bean
AuthSecurityProperties properties() {
return new AuthSecurityProperties("unused", "auth-service", "lastleg-api",
return new AuthSecurityProperties("auth-service", "lastleg-api",
Duration.ofMinutes(30),
Duration.ofDays(7), 5, Duration.ofMinutes(10));
}
}
@Test
void signsCrossRegionClaimsAndStoresSession() {
void signsUnifiedClaimsAndStoresSession() {
AuthAccount account = new AuthAccount();
account.setId(42L);
account.setUsername("operator");
@@ -71,9 +74,6 @@ 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,
snapshot.getRoles(), snapshot.getPermissions()));
var response = service.login(new LoginRequest("operator", "secret"));
@@ -89,16 +89,13 @@ class AuthServiceSmallJwtTest {
.containsEntry("overseas_warehouse_id", 900L)
.doesNotContainKeys("roles", "authorities", "permissions", "scope");
assertThat(jwt.getValue().getClaims().getAudience()).containsExactly("lastleg-api");
assertThat(jwt.getValue().getJwsHeader().getAlgorithm()).isEqualTo(MacAlgorithm.HS256);
assertThat(jwt.getValue().getJwsHeader().getKeyId()).isNull();
assertThat(jwt.getValue().getJwsHeader().getAlgorithm()).isEqualTo(SignatureAlgorithm.RS256);
assertThat(jwt.getValue().getJwsHeader().getKeyId()).isEqualTo("current");
verify(tokenSessionStore).store(any(), org.mockito.ArgumentMatchers.eq(42L),
org.mockito.ArgumentMatchers.eq(Set.of("OVERSEAS_WAREHOUSE_PDA_OPERATOR")),
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);
assertThat(response.roles()).containsExactly("OVERSEAS_WAREHOUSE_PDA_OPERATOR");
assertThat(response.authorities()).containsExactly("platform:order:list");
}
@@ -144,7 +141,7 @@ class AuthServiceSmallJwtTest {
}
@Test
void refreshRotatesDomesticAndOverseasTokensFromOneAuthorizationSnapshot() {
void refreshRotatesUnifiedTokenFromCurrentAuthorizationSnapshot() {
AuthAccount account = new AuthAccount();
account.setId(52L);
account.setUsername("merchant");
@@ -164,9 +161,6 @@ class AuthServiceSmallJwtTest {
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,
snapshot.getRoles(), snapshot.getPermissions()));
var response = service.refresh("refresh-old");
@@ -175,8 +169,6 @@ class AuthServiceSmallJwtTest {
assertThat(response.expiresIn()).isEqualTo(1800);
assertThat(response.refreshToken()).isNotBlank().isNotEqualTo("refresh-old");
assertThat(response.refreshExpiresIn()).isEqualTo(604800);
assertThat(response.overseasAccessToken()).isEqualTo("overseas-refreshed");
assertThat(response.overseasExpiresIn()).isEqualTo(900);
assertThat(response.roles()).containsExactly("MERCHANT_ADMIN");
assertThat(response.authorities()).containsExactly("merchant:order:list");
verify(tokenSessionStore).consume("refresh-old");
@@ -187,7 +179,6 @@ class AuthServiceSmallJwtTest {
org.mockito.ArgumentMatchers.eq(Duration.ofMinutes(30)),
org.mockito.ArgumentMatchers.eq(Duration.ofDays(7)));
verify(authorizationLoader).load(account);
verify(overseasTokenService).issue(account, snapshot);
}
@Test
@@ -200,7 +191,6 @@ class AuthServiceSmallJwtTest {
verify(repository, never()).findById(any());
verify(jwtEncoder, never()).encode(any());
verify(overseasTokenService, never()).issue(any(), any());
}
@Test
@@ -225,14 +215,9 @@ class AuthServiceSmallJwtTest {
when(authorizationLoader.load(account)).thenReturn(snapshot);
when(jwtEncoder.encode(any())).thenReturn(encoded);
when(encoded.getTokenValue()).thenReturn("small-impersonation-token");
when(overseasTokenService.issue(account, snapshot)).thenReturn(
new OverseasTokenService.OverseasTokenResponse("Bearer", "overseas-impersonation-token", 900,
Set.of("MERCHANT_OWNER"), Set.of("merchant:order:list")));
AuthService.ImpersonationTokenResponse response = service.exchangeImpersonationTicket("one-time-code");
assertThat(response.overseasAccessToken()).isEqualTo("overseas-impersonation-token");
assertThat(response.overseasExpiresIn()).isEqualTo(900);
assertThat(response.roles()).containsExactly("MERCHANT_OWNER");
assertThat(response.authorities()).containsExactly("merchant:order:list");
@@ -247,6 +232,5 @@ class AuthServiceSmallJwtTest {
org.mockito.ArgumentMatchers.eq(Set.of("MERCHANT_OWNER")),
org.mockito.ArgumentMatchers.eq(Set.of("merchant:order:list")),
org.mockito.ArgumentMatchers.eq(Duration.ofMinutes(15)));
verify(overseasTokenService).issue(account, snapshot);
}
}
@@ -1,139 +0,0 @@
package com.baiyue.auth.service;
import com.baiyue.auth.client.AuthorizationLoader;
import com.baiyue.auth.client.AuthorizationSnapshot;
import com.baiyue.auth.config.OverseasJwtProperties;
import com.baiyue.auth.entity.AuthAccount;
import com.baiyue.auth.repository.AuthAccountRepository;
import jakarta.annotation.Resource;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.jwt.JwtEncoder;
import org.springframework.security.oauth2.jwt.JwtEncoderParameters;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
import org.springframework.test.context.TestPropertySource;
import java.time.Duration;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.when;
@SpringJUnitConfig(classes = {OverseasTokenService.class, OverseasTokenServiceTest.Config.class})
@TestPropertySource(properties = "baiyue.security.overseas-jwt.enabled=true")
class OverseasTokenServiceTest {
@MockBean private AuthAccountRepository accounts;
@MockBean private AuthorizationLoader authorizationLoader;
@MockBean(name = "overseasJwtEncoder") private JwtEncoder encoder;
@Resource private OverseasTokenService service;
@Configuration(proxyBeanMethods = false)
static class Config {
@Bean OverseasJwtProperties properties() {
return new OverseasJwtProperties(true, "auth-service", "lastleg-api", "{}", "current-kid",
Duration.ofMinutes(15));
}
}
@Test
void signsShortRs256TokenWithCrossRegionClaims() {
AuthAccount account = new AuthAccount();
account.setId(42L); account.setAccountStatus("ACTIVE"); account.setDefaultTenantId(999L);
account.setDefaultMembershipId(84L); account.setDefaultAppCode("MERCHANT");
String authority = "platform:overseas-master-data:quote:list";
String merchantAuthority = "merchant:warehouse:delivery:list";
String merchantCrossBorderAuthority = "merchant:cross-border:procurement:list";
String lastlegAuthority = "lastleg:pda:operate";
String financeAuthority = "platform:finance:fee-source:edit";
String domesticAuthority = "platform:firstleg:order:list";
AuthorizationSnapshot snapshot = new AuthorizationSnapshot(42L, 0L, 84L, "PLATFORM", "WEB",
Set.of("OVERSEAS_WAREHOUSE_PDA_OPERATOR"),
Set.of(authority, merchantAuthority, merchantCrossBorderAuthority, lastlegAuthority,
financeAuthority, domesticAuthority), 7L, 900L);
Jwt encoded = org.mockito.Mockito.mock(Jwt.class);
when(accounts.findById(42L)).thenReturn(account);
when(authorizationLoader.load(account)).thenReturn(snapshot);
when(encoder.encode(any())).thenReturn(encoded);
when(encoded.getTokenValue()).thenReturn("overseas-token");
var response = service.exchange(42L);
ArgumentCaptor<JwtEncoderParameters> captor = ArgumentCaptor.forClass(JwtEncoderParameters.class);
verify(encoder).encode(captor.capture());
var parameters = captor.getValue();
assertThat(parameters.getJwsHeader().getAlgorithm()).isEqualTo(SignatureAlgorithm.RS256);
assertThat(parameters.getJwsHeader().getKeyId()).isEqualTo("current-kid");
assertThat(parameters.getClaims().getAudience()).containsExactly("lastleg-api");
assertThat(parameters.getClaims().getClaims())
.containsEntry("user_id", 42L).containsEntry("tenant_id", 0L)
.containsEntry("membership_id", 84L)
.containsEntry("app_code", "PLATFORM").containsEntry("client_type", "WEB")
.containsEntry("permission_version", 7L)
.containsEntry("token_version", 0)
.containsEntry("overseas_warehouse_id", 900L)
.doesNotContainKeys("roles", "authorities", "permissions", "userId", "tenantId", "appCode",
"permissionVersion");
assertThat(response.expiresIn()).isEqualTo(900);
assertThat(response.roles()).containsExactly("OVERSEAS_WAREHOUSE_PDA_OPERATOR");
assertThat(response.authorities()).containsExactlyInAnyOrder(authority, merchantAuthority,
merchantCrossBorderAuthority, lastlegAuthority, financeAuthority, domesticAuthority);
}
@Test
void refreshedAuthorizationSnapshotReplacesWarehouseBindingAndTokenIdentity() {
AuthAccount account = new AuthAccount();
account.setId(43L); account.setAccountStatus("ACTIVE"); account.setDefaultTenantId(0L);
account.setDefaultMembershipId(85L); account.setDefaultAppCode("PLATFORM");
account.setTokenVersion(8);
AuthorizationSnapshot oldBinding = new AuthorizationSnapshot(43L, 0L, 85L, "PLATFORM", "PDA",
Set.of("OVERSEAS_WAREHOUSE_PDA_OPERATOR"), Set.of("lastleg:pda:operate"), 8L, 900L);
AuthorizationSnapshot newBinding = new AuthorizationSnapshot(43L, 0L, 85L, "PLATFORM", "PDA",
Set.of("OVERSEAS_WAREHOUSE_PDA_OPERATOR"), Set.of("lastleg:pda:operate"), 9L, 901L);
Jwt first = org.mockito.Mockito.mock(Jwt.class);
Jwt second = org.mockito.Mockito.mock(Jwt.class);
when(encoder.encode(any())).thenReturn(first, second);
when(first.getTokenValue()).thenReturn("overseas-old-binding");
when(second.getTokenValue()).thenReturn("overseas-new-binding");
var oldToken = service.issue(account, oldBinding);
var newToken = service.issue(account, newBinding);
ArgumentCaptor<JwtEncoderParameters> captor = ArgumentCaptor.forClass(JwtEncoderParameters.class);
verify(encoder, times(2)).encode(captor.capture());
var oldClaims = captor.getAllValues().get(0).getClaims();
var newClaims = captor.getAllValues().get(1).getClaims();
assertThat(oldClaims.getId()).isNotEqualTo(newClaims.getId());
assertThat(oldClaims.getClaims()).containsEntry("overseas_warehouse_id", 900L)
.containsEntry("permission_version", 8L);
assertThat(newClaims.getClaims()).containsEntry("overseas_warehouse_id", 901L)
.containsEntry("permission_version", 9L);
assertThat(newClaims.getClaims()).containsEntry("token_version", 8);
assertThat(oldToken.accessToken()).isNotEqualTo(newToken.accessToken());
}
@Test
void unboundWarehouseIsOmittedSoPdaIdentityMustFailClosed() {
AuthAccount account = new AuthAccount();
account.setId(44L); account.setAccountStatus("ACTIVE");
AuthorizationSnapshot unbound = new AuthorizationSnapshot(44L, 0L, 86L, "PLATFORM", "PDA",
Set.of("OVERSEAS_WAREHOUSE_PDA_OPERATOR"), Set.of("lastleg:pda:operate"), 10L, null);
Jwt encoded = org.mockito.Mockito.mock(Jwt.class);
when(encoder.encode(any())).thenReturn(encoded);
when(encoded.getTokenValue()).thenReturn("overseas-unbound");
service.issue(account, unbound);
ArgumentCaptor<JwtEncoderParameters> captor = ArgumentCaptor.forClass(JwtEncoderParameters.class);
verify(encoder).encode(captor.capture());
assertThat(captor.getValue().getClaims().getClaims())
.doesNotContainKey("overseas_warehouse_id");
}
}