优化
This commit is contained in:
@@ -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) { }
|
||||
+8
-10
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user