暂存提交

This commit is contained in:
2026-08-13 19:01:16 +08:00
parent 8ba04fc87d
commit 073cb6a577
50 changed files with 981 additions and 136 deletions
@@ -20,4 +20,5 @@ public class AuthorizationSnapshot {
private String clientType;
private Set<String> roles;
private Set<String> permissions;
private Long permissionVersion;
}
@@ -1,6 +1,5 @@
package com.baiyue.auth.config;
import com.nimbusds.jose.jwk.source.ImmutableSecret;
import com.baiyue.common.security.JwtAuthenticationFilter;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
@@ -10,14 +9,11 @@ import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.oauth2.jose.jws.MacAlgorithm;
import org.springframework.security.oauth2.jwt.JwtEncoder;
import org.springframework.security.oauth2.jwt.NimbusJwtEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import java.nio.charset.StandardCharsets;
/**
* 认证服务安全配置,集中声明密码编码器、JWT 编码器以及认证接口的安全过滤链。
@@ -31,6 +27,7 @@ public class AuthSecurityConfig {
"/api/auth/refresh",
"/api/auth/logout",
"/api/auth/impersonations/exchange",
"/api/auth/.well-known/jwks.json",
"/actuator/health",
"/v3/api-docs/**",
"/swagger-ui/**",
@@ -40,13 +37,6 @@ public class AuthSecurityConfig {
@Bean
PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(12); }
@Bean
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));
}
@Bean
SecurityFilterChain authSecurityFilterChain(HttpSecurity http,
JwtAuthenticationFilter jwtFilter,
@@ -0,0 +1,22 @@
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));
}
}
@@ -0,0 +1,37 @@
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;
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) {
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 密钥");
}
if (set.getKeys().stream().anyMatch(jwk -> !(jwk instanceof RSAKey))) {
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("海外非当前密钥只能配置公钥");
}
return new NimbusJwtEncoder(new ImmutableJWKSet<>(set));
} catch (java.text.ParseException exception) {
throw new IllegalStateException("海外 RSA JWK Set 配置无效", exception);
}
}
}
@@ -0,0 +1,10 @@
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) { }
@@ -23,6 +23,8 @@ 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;
/**
@@ -38,6 +40,18 @@ 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()")
@@ -0,0 +1,31 @@
package com.baiyue.auth.controller;
import com.baiyue.auth.config.OverseasJwtProperties;
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;
import org.springframework.web.bind.annotation.RestController;
import java.util.Map;
import java.util.concurrent.TimeUnit;
/** 仅在海外 JWT 显式启用后发布 RSA 公钥。 */
@RestController
@ConditionalOnProperty(name = "baiyue.security.overseas-jwt.enabled", havingValue = "true")
public class JwksController {
private final OverseasJwtProperties properties;
public JwksController(OverseasJwtProperties properties) { this.properties = properties; }
@GetMapping("/api/auth/.well-known/jwks.json")
@SecurityRequirements
@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());
}
}
@@ -30,7 +30,6 @@ import java.time.LocalDateTime;
import java.util.Base64;
import java.util.UUID;
import java.time.Duration;
import java.util.List;
/**
* 统一认证应用服务,编排密码校验、IAM 权限快照加载、小 JWT 签发、刷新令牌轮换与注销事务。
@@ -109,8 +108,7 @@ public class AuthService {
String jwtId = UUID.randomUUID().toString();
Instant expiresAt = issuedAt.plus(properties.accessTokenTtl());
JwtClaimsSet claims = JwtClaimsSet.builder()
.issuer(properties.issuer()).audience(List.of(properties.audience()))
.id(jwtId).issuedAt(issuedAt).expiresAt(expiresAt)
.issuer(properties.issuer()).id(jwtId).issuedAt(issuedAt).expiresAt(expiresAt)
.subject(account.getId().toString())
.claim("user_id", account.getId())
.claim("username", account.getUsername())
@@ -121,12 +119,6 @@ public class AuthService {
.claim("client_type", authorization.getClientType())
.claim("token_version", account.getTokenVersion())
.claim("password_change_required", Boolean.TRUE.equals(account.getPasswordChangeRequired()))
.claim("userId", account.getId())
.claim("tenantId", account.getDefaultTenantId())
.claim("appCode", account.getDefaultAppCode())
.claim("roles", authorization.getRoles())
.claim("authorities", authorization.getPermissions())
.claim("permissionVersion", account.getTokenVersion())
.build();
String accessToken = jwtEncoder.encode(JwtEncoderParameters.from(
signingHeader(), claims)).getTokenValue();
@@ -175,17 +167,12 @@ public class AuthService {
Instant issuedAt = Instant.now();
Instant expiresAt = issuedAt.plus(Duration.ofMinutes(15));
String jwtId = UUID.randomUUID().toString();
JwtClaimsSet claims = JwtClaimsSet.builder().issuer(properties.issuer())
.audience(List.of(properties.audience())).id(jwtId)
JwtClaimsSet claims = JwtClaimsSet.builder().issuer(properties.issuer()).id(jwtId)
.issuedAt(issuedAt).expiresAt(expiresAt).subject(account.getId().toString())
.claim("user_id", account.getId()).claim("username", account.getUsername())
.claim("tenant_id", account.getDefaultTenantId()).claim("operator_tenant_id", account.getOperatorTenantId())
.claim("membership_id", account.getDefaultMembershipId()).claim("app_code", "MERCHANT")
.claim("client_type", authorization.getClientType()).claim("token_version", account.getTokenVersion())
.claim("userId", account.getId()).claim("tenantId", account.getDefaultTenantId())
.claim("appCode", "MERCHANT").claim("roles", authorization.getRoles())
.claim("authorities", authorization.getPermissions())
.claim("permissionVersion", account.getTokenVersion())
.claim("impersonated", true).claim("impersonation_id", ticket.ticketId())
.claim("actor_user_id", ticket.actorUserId()).claim("actor_membership_id", ticket.actorMembershipId())
.claim("password_change_required", Boolean.TRUE.equals(account.getPasswordChangeRequired())).build();
@@ -0,0 +1,56 @@
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);
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("authorities", authorization.getPermissions())
.claim("permissionVersion", authorization.getPermissionVersion() == null ? 0L : authorization.getPermissionVersion())
.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());
}
public record OverseasTokenResponse(String tokenType, String accessToken, long expiresIn) { }
}