优化
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user