Commit 254eae5a by jianan

feat(转介人门户): 票桥接力后端——/c/sso/ticket 签发/消费与多值 audience(design D11)

- ReferrerAuthenticator:验签+sub绑定+状态/审批门禁的唯一实现,过滤器与票桥共用
- SsoTicketController:issue 在鉴权链内(签发即完整认证);consume 匿名 permitAll、
  票即凭证,消费端重验绑定与门禁后返回 token+clientUserBizId
- SsoTicketService/RedisReferrerTicketStore:30s TTL、Lua 原子取即焚、同票重放必拒
- audience 由单值 contains 改逗号分隔接受集合交集语义(工作台+小程序双应用,D2 口径)
- issue 单端点 CORS 白名单(referrer.ticket.allowed-origins,默认空=仅同源)
- ReferrerIdentityFilter 瘦身委托 authenticator,mock 兜底链路不变
- 单测 11 例;本地 9202 E2E:issue/consume/重放/假票/多值 aud 回归全过
parent 24559d5c
package com.yd.csf.api.security;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.stereotype.Component;
import javax.annotation.Resource;
import java.util.Collections;
import java.util.concurrent.TimeUnit;
/**
* Redis 票存储:复用应用既有 {@code redisTemplate}(与 ReceivableService 同款按名注入,
* 规避 RedisTemplate 泛型擦除下的 byType 歧义)。
* getAndDelete 用 Lua 保证原子(GET+DEL 单命令语义,不依赖 Redis ≥ 6.2 的 GETDEL)。
*/
@Component
public class RedisReferrerTicketStore implements ReferrerTicketStore {
private static final DefaultRedisScript<String> GET_AND_DELETE = new DefaultRedisScript<>(
"local v = redis.call('GET', KEYS[1]);\n"
+ "if v then redis.call('DEL', KEYS[1]) end\n"
+ "return v", String.class);
@Resource
private RedisTemplate<String, String> redisTemplate;
@Override
public void put(String key, String token, long ttlSeconds) {
redisTemplate.opsForValue().set(key, token, ttlSeconds, TimeUnit.SECONDS);
}
@Override
public String getAndDelete(String key) {
return redisTemplate.execute(GET_AND_DELETE, Collections.singletonList(key));
}
}
package com.yd.csf.api.security;
import com.yd.common.enums.CommonEnum;
import com.yd.common.result.Result;
import com.yd.user.feign.client.clientuser.ApiClientUserFeignClient;
import com.yd.user.feign.response.clientuser.ApiClientUserIdpLoginResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator;
import org.springframework.security.oauth2.core.OAuth2TokenValidator;
import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.jwt.JwtClaimValidator;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.oauth2.jwt.JwtException;
import org.springframework.security.oauth2.jwt.JwtIssuerValidator;
import org.springframework.security.oauth2.jwt.JwtTimestampValidator;
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Objects;
import java.util.Set;
import java.util.stream.Collectors;
/**
* Casdoor 令牌的「验签 + sub 绑定 + 状态/审批门禁」唯一实现。
*
* <p>{@link ReferrerIdentityFilter}({@code /c/**} 链入口)与票桥
* {@link SsoTicketService}(D11,签发即完整认证 / 消费重验)共用本组件,
* 保证「能换票」与「能拿数据」永远同一口径,不会两处门禁漂移。</p>
*
* <p>aud 校验为<b>接受集合</b>语义(design D11 / tasks 11.2):配置的 audience
* 逗号分隔多值(工作台应用 + 小程序侧应用),令牌 {@code aud} 数组与接受集合
* 相交即通过;接受集合外的应用签发一律拒绝。主体定位只认 {@code sub},
* 与 aud 是哪个应用正交(D2)。</p>
*/
@Slf4j
@Component
public class ReferrerAuthenticator {
private final ReferrerOidcProperties properties;
private final ApiClientUserFeignClient clientUserFeignClient;
/** jwk-set-uri 未配置时为 null:不验签,调用方据此退化为开发期 mock(本地核对链路) */
private final JwtDecoder jwtDecoder;
public ReferrerAuthenticator(ReferrerOidcProperties properties,
ApiClientUserFeignClient clientUserFeignClient) {
this.properties = properties;
this.clientUserFeignClient = clientUserFeignClient;
this.jwtDecoder = buildJwtDecoder(properties);
}
public boolean isDecoderAvailable() {
return jwtDecoder != null;
}
/**
* 对原始 token 做验签/iss/aud/时效 + sub 绑定 + 门禁,全部通过返回 clientUserBizId;
* 任一环节失败返回 null(原因只进日志,不向调用方泄露细节)。
*/
public String authenticate(String rawToken) {
if (jwtDecoder == null || !StringUtils.hasText(rawToken)) {
return null;
}
Jwt jwt;
try {
jwt = jwtDecoder.decode(rawToken);
} catch (JwtException | IllegalArgumentException e) {
log.warn("JWT 验签/校验失败:{}", e.getMessage());
return null;
}
String sub = jwt.getSubject();
if (!StringUtils.hasText(sub)) {
log.warn("JWT 缺少 sub claim");
return null;
}
return resolveGated(sub);
}
/**
* sub →(租户内)client_user.idp_sub 定位主档案,并强制在职/审批门禁。
*/
private String resolveGated(String sub) {
Result<ApiClientUserIdpLoginResponse> result =
clientUserFeignClient.getByIdpSub(properties.getTenantBizId(), sub);
if (Objects.isNull(result)) {
log.warn("调用 yd-user getByIdpSub 失败(Feign 降级返回 null),tenantBizId={} sub={}",
properties.getTenantBizId(), sub);
return null;
}
if (Objects.isNull(result.getData())) {
log.warn("IdP 身份未绑定 client_user 或租户不匹配,tenantBizId={} sub={} code={} msg={}",
properties.getTenantBizId(), sub, result.getCode(), result.getMsg());
return null;
}
ApiClientUserIdpLoginResponse login = result.getData();
if (!Integer.valueOf(1).equals(login.getStatus())) {
log.warn("转介人账号已停用 status={} clientUserBizId={}", login.getStatus(), login.getClientUserBizId());
return null;
}
if (!CommonEnum.APPROVAL_STATUS_CG.getCode().equals(login.getApprovalStatus())) {
log.warn("转介人审批状态不可登录 approvalStatus={} clientUserBizId={}",
login.getApprovalStatus(), login.getClientUserBizId());
return null;
}
return login.getClientUserBizId();
}
static JwtDecoder buildJwtDecoder(ReferrerOidcProperties properties) {
if (!StringUtils.hasText(properties.getJwkSetUri())) {
return null;
}
NimbusJwtDecoder decoder = NimbusJwtDecoder.withJwkSetUri(properties.getJwkSetUri())
.jwsAlgorithm(SignatureAlgorithm.RS256)
.build();
List<OAuth2TokenValidator<Jwt>> validators = new ArrayList<>();
validators.add(new JwtTimestampValidator());
if (StringUtils.hasText(properties.getIssuer())) {
validators.add(new JwtIssuerValidator(properties.getIssuer()));
}
Set<String> accepted = parseAcceptedAudiences(properties.getAudience());
if (!accepted.isEmpty()) {
validators.add(audienceValidator(accepted));
}
decoder.setJwtValidator(new DelegatingOAuth2TokenValidator<>(validators));
return decoder;
}
/** audience 配置为逗号分隔接受集合;空白项剔除,全空视为未配置(跳过 aud 校验)。 */
static Set<String> parseAcceptedAudiences(String audienceConfig) {
if (!StringUtils.hasText(audienceConfig)) {
return new LinkedHashSet<>();
}
return Arrays.stream(audienceConfig.split(","))
.map(String::trim)
.filter(s -> !s.isEmpty())
.collect(Collectors.toCollection(LinkedHashSet::new));
}
static OAuth2TokenValidator<Jwt> audienceValidator(Set<String> acceptedAudiences) {
return new JwtClaimValidator<Collection<?>>("aud",
aud -> aud != null && acceptedAudiences.stream().anyMatch(a -> aud.contains(a)));
}
}
package com.yd.csf.api.security; package com.yd.csf.api.security;
import com.yd.common.enums.CommonEnum;
import com.yd.common.result.Result;
import com.yd.user.feign.client.clientuser.ApiClientUserFeignClient;
import com.yd.user.feign.response.clientuser.ApiClientUserIdpLoginResponse;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.ObjectProvider; import org.springframework.beans.factory.ObjectProvider;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.oauth2.jwt.JwtException;
import org.springframework.util.StringUtils; import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter; import org.springframework.web.filter.OncePerRequestFilter;
...@@ -20,16 +13,15 @@ import javax.servlet.http.HttpServletRequest; ...@@ -20,16 +13,15 @@ import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse; import javax.servlet.http.HttpServletResponse;
import java.io.IOException; import java.io.IOException;
import java.util.Collections; import java.util.Collections;
import java.util.Objects;
/** /**
* 转介人门户身份过滤器。 * 转介人门户身份过滤器。
* *
* <p>两种身份来源,按优先级:</p> * <p>两种身份来源,按优先级:</p>
* <ol> * <ol>
* <li>请求携带 {@code Authorization: Bearer} 且配置了 JWKS:用 {@link JwtDecoder} 校验 * <li>请求携带 {@code Authorization: Bearer} 且配置了 JWKS:交 {@link ReferrerAuthenticator}
* RS256 + iss + aud,取 {@code sub} 调 yd-user 解析客户端用户, * 验签(RS256 + iss + aud 接受集合)并按 {@code sub} 解析转介人,
* 并强制 {@code status=1 && approval_status=CG} 门禁;</li> * 强制 {@code status=1 && approval_status=CG} 门禁;</li>
* <li>Bearer 未建立身份但存在 {@link DevMockIdentitySupplier}(仅 local-prod profile): * <li>Bearer 未建立身份但存在 {@link DevMockIdentitySupplier}(仅 local-prod profile):
* 按请求头模拟转介人身份;</li> * 按请求头模拟转介人身份;</li>
* <li>以上均无、但配置了 {@code referrer.oidc.mock-client-user-biz-id}:注入固定身份, * <li>以上均无、但配置了 {@code referrer.oidc.mock-client-user-biz-id}:注入固定身份,
...@@ -44,17 +36,14 @@ public class ReferrerIdentityFilter extends OncePerRequestFilter { ...@@ -44,17 +36,14 @@ public class ReferrerIdentityFilter extends OncePerRequestFilter {
private static final String BEARER_PREFIX = "Bearer "; private static final String BEARER_PREFIX = "Bearer ";
private final ReferrerOidcProperties properties; private final ReferrerOidcProperties properties;
private final ApiClientUserFeignClient clientUserFeignClient; private final ReferrerAuthenticator authenticator;
private final JwtDecoder jwtDecoder;
private final ObjectProvider<DevMockIdentitySupplier> mockSupplierProvider; private final ObjectProvider<DevMockIdentitySupplier> mockSupplierProvider;
public ReferrerIdentityFilter(ReferrerOidcProperties properties, public ReferrerIdentityFilter(ReferrerOidcProperties properties,
ApiClientUserFeignClient clientUserFeignClient, ReferrerAuthenticator authenticator,
JwtDecoder jwtDecoder,
ObjectProvider<DevMockIdentitySupplier> mockSupplierProvider) { ObjectProvider<DevMockIdentitySupplier> mockSupplierProvider) {
this.properties = properties; this.properties = properties;
this.clientUserFeignClient = clientUserFeignClient; this.authenticator = authenticator;
this.jwtDecoder = jwtDecoder;
this.mockSupplierProvider = mockSupplierProvider; this.mockSupplierProvider = mockSupplierProvider;
} }
...@@ -65,10 +54,16 @@ public class ReferrerIdentityFilter extends OncePerRequestFilter { ...@@ -65,10 +54,16 @@ public class ReferrerIdentityFilter extends OncePerRequestFilter {
boolean authenticated = false; boolean authenticated = false;
String token = resolveBearerToken(request); String token = resolveBearerToken(request);
if (StringUtils.hasText(token) && jwtDecoder != null) { if (StringUtils.hasText(token)) {
authenticated = authenticateWithJwt(token); if (authenticator.isDecoderAvailable()) {
} else if (StringUtils.hasText(token)) { String clientUserBizId = authenticator.authenticate(token);
log.warn("收到 Bearer token 但 JwtDecoder 未初始化:referrer.oidc.jwk-set-uri 是否配置?"); if (clientUserBizId != null) {
applyIdentity(clientUserBizId);
authenticated = true;
}
} else {
log.warn("收到 Bearer token 但 JwtDecoder 未初始化:referrer.oidc.jwk-set-uri 是否配置?");
}
} }
// Bearer 未建立身份时,dev profile 下尝试请求头模拟身份(真实凭证优先) // Bearer 未建立身份时,dev profile 下尝试请求头模拟身份(真实凭证优先)
...@@ -104,52 +99,6 @@ public class ReferrerIdentityFilter extends OncePerRequestFilter { ...@@ -104,52 +99,6 @@ public class ReferrerIdentityFilter extends OncePerRequestFilter {
} }
} }
/**
* 验签并按 sub 解析转介人身份;任何校验失败返回 false(不抛错、不泄露原因)。
*/
private boolean authenticateWithJwt(String token) {
Jwt jwt;
try {
jwt = jwtDecoder.decode(token);
} catch (JwtException | IllegalArgumentException e) {
log.warn("JWT 验签/校验失败:{}", e.getMessage());
return false;
}
String sub = jwt.getSubject();
if (!StringUtils.hasText(sub)) {
log.warn("JWT 缺少 sub claim");
return false;
}
Result<ApiClientUserIdpLoginResponse> result =
clientUserFeignClient.getByIdpSub(properties.getTenantBizId(), sub);
if (Objects.isNull(result)) {
log.warn("调用 yd-user getByIdpSub 失败(Feign 降级返回 null),tenantBizId={} sub={}",
properties.getTenantBizId(), sub);
return false;
}
if (Objects.isNull(result.getData())) {
log.warn("IdP 身份未绑定 client_user 或租户不匹配,tenantBizId={} sub={} code={} msg={}",
properties.getTenantBizId(), sub, result.getCode(), result.getMsg());
return false;
}
ApiClientUserIdpLoginResponse login = result.getData();
if (!Integer.valueOf(1).equals(login.getStatus())) {
log.warn("转介人账号已停用 status={} clientUserBizId={}", login.getStatus(), login.getClientUserBizId());
return false;
}
if (!CommonEnum.APPROVAL_STATUS_CG.getCode().equals(login.getApprovalStatus())) {
log.warn("转介人审批状态不可登录 approvalStatus={} clientUserBizId={}",
login.getApprovalStatus(), login.getClientUserBizId());
return false;
}
applyIdentity(login.getClientUserBizId());
return true;
}
private void applyIdentity(String clientUserBizId) { private void applyIdentity(String clientUserBizId) {
ReferrerIdentityContext.setClientUserBizId(clientUserBizId); ReferrerIdentityContext.setClientUserBizId(clientUserBizId);
UsernamePasswordAuthenticationToken authentication = UsernamePasswordAuthenticationToken authentication =
...@@ -157,7 +106,7 @@ public class ReferrerIdentityFilter extends OncePerRequestFilter { ...@@ -157,7 +106,7 @@ public class ReferrerIdentityFilter extends OncePerRequestFilter {
SecurityContextHolder.getContext().setAuthentication(authentication); SecurityContextHolder.getContext().setAuthentication(authentication);
} }
private String resolveBearerToken(HttpServletRequest request) { static String resolveBearerToken(HttpServletRequest request) {
String header = request.getHeader("Authorization"); String header = request.getHeader("Authorization");
if (StringUtils.hasText(header) && header.startsWith(BEARER_PREFIX)) { if (StringUtils.hasText(header) && header.startsWith(BEARER_PREFIX)) {
return header.substring(BEARER_PREFIX.length()).trim(); return header.substring(BEARER_PREFIX.length()).trim();
......
...@@ -30,6 +30,14 @@ public class ReferrerOidcProperties { ...@@ -30,6 +30,14 @@ public class ReferrerOidcProperties {
@Value("${referrer.oidc.mock-client-user-biz-id:}") @Value("${referrer.oidc.mock-client-user-biz-id:}")
private String mockClientUserBizId; private String mockClientUserBizId;
/**
* 票桥签发端点({@code /c/sso/ticket/issue})允许的跨域来源,逗号分隔
* (design D11:CFFP H5 与工作台不同源,换票是跨域携带 Authorization 的 POST)。
* 空 = 不额外放行任何跨域来源(同源使用不受影响)。
*/
@Value("${referrer.ticket.allowed-origins:}")
private String ticketAllowedOrigins;
public String getJwkSetUri() { public String getJwkSetUri() {
return jwkSetUri; return jwkSetUri;
} }
...@@ -49,4 +57,8 @@ public class ReferrerOidcProperties { ...@@ -49,4 +57,8 @@ public class ReferrerOidcProperties {
public String getMockClientUserBizId() { public String getMockClientUserBizId() {
return mockClientUserBizId; return mockClientUserBizId;
} }
public String getTicketAllowedOrigins() {
return ticketAllowedOrigins;
}
} }
\ No newline at end of file
package com.yd.csf.api.security; package com.yd.csf.api.security;
import com.yd.user.feign.client.clientuser.ApiClientUserFeignClient;
import org.springframework.beans.factory.ObjectProvider; import org.springframework.beans.factory.ObjectProvider;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import org.springframework.core.annotation.Order; import org.springframework.core.annotation.Order;
...@@ -8,21 +7,15 @@ import org.springframework.http.HttpStatus; ...@@ -8,21 +7,15 @@ import org.springframework.http.HttpStatus;
import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter; import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.oauth2.core.OAuth2TokenValidator;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.jwt.JwtClaimValidator;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.oauth2.jwt.JwtIssuerValidator;
import org.springframework.security.oauth2.jwt.JwtTimestampValidator;
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator;
import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm;
import org.springframework.security.web.authentication.HttpStatusEntryPoint; import org.springframework.security.web.authentication.HttpStatusEntryPoint;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.util.StringUtils; import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Collection; import java.util.Arrays;
import java.util.Collections;
import java.util.List; import java.util.List;
/** /**
...@@ -32,8 +25,12 @@ import java.util.List; ...@@ -32,8 +25,12 @@ import java.util.List;
* ({@code /**} 兜底)。转介人门户的所有接口都必须挂在该前缀下,才能进入本链。</p> * ({@code /**} 兜底)。转介人门户的所有接口都必须挂在该前缀下,才能进入本链。</p>
* *
* <p><b>身份来源</b>:Casdoor OIDC 签发的 access token,由 {@link ReferrerIdentityFilter} * <p><b>身份来源</b>:Casdoor OIDC 签发的 access token,由 {@link ReferrerIdentityFilter}
* 用 JWKS 验签(RS256 + iss + aud),再按 {@code sub} 调 yd-user 解析转介人并做门禁。 * 交 {@link ReferrerAuthenticator} 验签(RS256 + iss + aud 接受集合),再按 {@code sub}
* 认证完全由本服务完成,<b>不依赖网关注入身份</b>。</p> * 调 yd-user 解析转介人并做门禁。认证完全由本服务完成,<b>不依赖网关注入身份</b>。</p>
*
* <p><b>票桥(D11)</b>:{@code /c/sso/ticket/issue} 走完整认证(链内已鉴权,签发即门禁全过);
* {@code /c/sso/ticket/consume} 对本链匿名放行——票据本身即凭证(30s/一次性/消费即焚),
* 消费端重验主体绑定与门禁后才建会话,不扩大信任面。</p>
* *
* <p><b>部署约定(重要)</b>:本门户在 nginx 层直连 yd-csf-api,<b>不经过内勤网关</b>; * <p><b>部署约定(重要)</b>:本门户在 nginx 层直连 yd-csf-api,<b>不经过内勤网关</b>;
* 网关的统一 JWT 校验不认 Casdoor 签发方,走网关会导致登录后接口全 401、前端循环跳转。 * 网关的统一 JWT 校验不认 Casdoor 签发方,走网关会导致登录后接口全 401、前端循环跳转。
...@@ -50,20 +47,24 @@ import java.util.List; ...@@ -50,20 +47,24 @@ import java.util.List;
public class ReferrerPortalSecurityConfig extends WebSecurityConfigurerAdapter { public class ReferrerPortalSecurityConfig extends WebSecurityConfigurerAdapter {
private final ReferrerOidcProperties properties; private final ReferrerOidcProperties properties;
private final ApiClientUserFeignClient clientUserFeignClient; private final ReferrerAuthenticator authenticator;
private final ObjectProvider<DevMockIdentitySupplier> mockSupplierProvider; private final ObjectProvider<DevMockIdentitySupplier> mockSupplierProvider;
public ReferrerPortalSecurityConfig(ReferrerOidcProperties properties, public ReferrerPortalSecurityConfig(ReferrerOidcProperties properties,
ApiClientUserFeignClient clientUserFeignClient, ReferrerAuthenticator authenticator,
ObjectProvider<DevMockIdentitySupplier> mockSupplierProvider) { ObjectProvider<DevMockIdentitySupplier> mockSupplierProvider) {
this.properties = properties; this.properties = properties;
this.clientUserFeignClient = clientUserFeignClient; this.authenticator = authenticator;
this.mockSupplierProvider = mockSupplierProvider; this.mockSupplierProvider = mockSupplierProvider;
} }
@Override @Override
protected void configure(HttpSecurity http) throws Exception { protected void configure(HttpSecurity http) throws Exception {
http.antMatcher("/c/**") http.antMatcher("/c/**")
// D11:仅票桥签发端点按白名单放行跨域(CFFP H5 → csf 不同源);
// CorsFilter 在授权之前短路应答 OPTIONS 预检,未列出的路径/来源一律不放行
.cors().configurationSource(buildTicketCorsSource())
.and()
.csrf().disable() .csrf().disable()
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and() .and()
...@@ -74,38 +75,36 @@ public class ReferrerPortalSecurityConfig extends WebSecurityConfigurerAdapter { ...@@ -74,38 +75,36 @@ public class ReferrerPortalSecurityConfig extends WebSecurityConfigurerAdapter {
.authorizeRequests() .authorizeRequests()
// 核对用 dev 选人接口无需身份;dev Controller 仅 local-prod 存在,其他 profile 下为 404 // 核对用 dev 选人接口无需身份;dev Controller 仅 local-prod 存在,其他 profile 下为 404
.antMatchers("/c/dev/**").permitAll() .antMatchers("/c/dev/**").permitAll()
// D11 票桥消费端:票即凭证;签发端 /c/sso/ticket/issue 仍需完整认证
.antMatchers("/c/sso/ticket/consume").permitAll()
.anyRequest().authenticated() .anyRequest().authenticated()
.and() .and()
// 链内手动 new:不加 @Component,避免被 Boot 注册成容器级 Filter 污染老后台 /** 链 // 链内手动 new:不加 @Component,避免被 Boot 注册成容器级 Filter 污染老后台 /** 链
.addFilterBefore(new ReferrerIdentityFilter(properties, clientUserFeignClient, .addFilterBefore(new ReferrerIdentityFilter(properties, authenticator, mockSupplierProvider),
buildJwtDecoder(), mockSupplierProvider),
UsernamePasswordAuthenticationFilter.class); UsernamePasswordAuthenticationFilter.class);
} }
/** /**
* 构建 RS256 JWKS 解码器,校验时间戳 + iss + aud(aud 为数组时要求包含配置 audience)。 * 票桥签发端点的最小 CORS:来源白名单来自 {@code referrer.ticket.allowed-origins}
* 未配置 jwk-set-uri 时返回 null,过滤器退化为开发期 mock 身份。 * (逗号分隔;空则不注册任何映射,跨域全部拒绝、同源不受影响)。
* 只允许 POST + Authorization/Content-Type,不带凭证(票与身份都在 header/body 里)。
*/ */
private JwtDecoder buildJwtDecoder() { private CorsConfigurationSource buildTicketCorsSource() {
if (!StringUtils.hasText(properties.getJwkSetUri())) { UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
return null; List<String> origins = new ArrayList<>();
} for (String o : properties.getTicketAllowedOrigins().split(",")) {
if (!o.trim().isEmpty()) {
NimbusJwtDecoder decoder = NimbusJwtDecoder.withJwkSetUri(properties.getJwkSetUri()) origins.add(o.trim());
.jwsAlgorithm(SignatureAlgorithm.RS256) }
.build();
List<OAuth2TokenValidator<Jwt>> validators = new ArrayList<>();
validators.add(new JwtTimestampValidator());
if (StringUtils.hasText(properties.getIssuer())) {
validators.add(new JwtIssuerValidator(properties.getIssuer()));
} }
if (StringUtils.hasText(properties.getAudience())) { if (!origins.isEmpty()) {
String audience = properties.getAudience(); CorsConfiguration cfg = new CorsConfiguration();
validators.add(new JwtClaimValidator<Collection<?>>("aud", cfg.setAllowedOrigins(origins);
aud -> aud != null && aud.contains(audience))); cfg.setAllowedMethods(Collections.singletonList("POST"));
cfg.setAllowedHeaders(Arrays.asList("Authorization", "Content-Type"));
cfg.setMaxAge(600L);
source.registerCorsConfiguration("/c/sso/ticket/issue", cfg);
} }
decoder.setJwtValidator(new DelegatingOAuth2TokenValidator<>(validators)); return source;
return decoder;
} }
} }
package com.yd.csf.api.security;
/**
* 一次性票据存储(design D11 票桥)。抽象出来以便:
* 1) 生产实现走 Redis(多实例安全);2) 单测用内存实现;3) 与 ajb 票桥同构,便于口径对照。
*/
public interface ReferrerTicketStore {
/** 存票:key → token,ttl 秒后自动失效 */
void put(String key, String token, long ttlSeconds);
/** 原子「取出并删除」:并发下仅一个调用者拿到值;不存在/已过期/已消费返回 null */
String getAndDelete(String key);
}
package com.yd.csf.api.security;
import com.yd.common.result.Result;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import javax.servlet.http.HttpServletRequest;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* 票桥接力端点(design D11):CFFP H5 用内存中的 Casdoor token 向 csf 换一次性票,
* 工作台 {@code /auth/ticket} 落地页凭票建会话,实现小程序入口零自证免登。
*
* <p><b>issue</b>:位于 {@code /c/**} 认证链的鉴权范围内——走到这里即代表
* {@link ReferrerAuthenticator} 验签 + sub 绑定 + 状态/审批门禁全部通过(签发即完整认证)。
* <b>consume</b>:链上匿名(票即凭证),但消费端重验认证,见 {@link SsoTicketService}。</p>
*/
@RestController
@RequestMapping("/c/sso/ticket")
public class SsoTicketController {
private final SsoTicketService ticketService;
public SsoTicketController(SsoTicketService ticketService) {
this.ticketService = ticketService;
}
@PostMapping("/issue")
public ResponseEntity<Result<Map<String, String>>> issue(HttpServletRequest request) {
// 鉴权由此链的过滤器完成;此处取原始 Bearer 作为票的载荷
String rawToken = ReferrerIdentityFilter.resolveBearerToken(request);
if (rawToken == null) {
return ResponseEntity.status(401).body(Result.fail("缺少 Casdoor Bearer 令牌"));
}
String ticket = ticketService.issue(rawToken);
return ResponseEntity.ok(Result.success(Collections.singletonMap("ticket", ticket)));
}
@PostMapping("/consume")
public ResponseEntity<Result<Map<String, Object>>> consume(@RequestParam("ticket") String ticket) {
SsoTicketService.TicketSession session = ticketService.consumeAndAuthenticate(ticket);
if (session == null) {
return ResponseEntity.status(401)
.body(Result.fail("票据无效、已过期或已使用"));
}
Map<String, Object> data = new LinkedHashMap<>();
data.put("token", session.token);
data.put("clientUserBizId", session.clientUserBizId);
return ResponseEntity.ok(Result.success(data));
}
}
package com.yd.csf.api.security;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import java.security.SecureRandom;
import java.util.Base64;
/**
* 票桥接力(design D11):web-view 原生/H5 之间传递「已认证身份」的一次性票据。
*
* <p>契约与链路2(ajb cffp 票桥)同构且更严:<b>签发即完整认证</b>——issue 端点位于
* {@code /c/**} 链内,能调用到这里的前提是 {@link ReferrerAuthenticator} 已全门禁通过;
* 票据默认 30 秒 TTL、一次性、<b>消费即焚</b>(存储层原子 get+del);消费端<b>重验</b>
* 验签与主体门禁后才返回会话凭据。票丢失窗口 ≤TTL 且单次可用,不引入长期凭证。</p>
*/
@Slf4j
@Service
public class SsoTicketService {
private static final String KEY_PREFIX = "csf:sso:ticket:";
private final SecureRandom secureRandom = new SecureRandom();
private final ReferrerTicketStore store;
private final ReferrerAuthenticator authenticator;
private final long ttlSeconds;
public SsoTicketService(ReferrerTicketStore store,
ReferrerAuthenticator authenticator,
@Value("${referrer.ticket.ttl-seconds:30}") long ttlSeconds) {
this.store = store;
this.authenticator = authenticator;
this.ttlSeconds = ttlSeconds;
}
/**
* 为已通过完整认证的原始 token 发票。仅由 {@code /c/**} 链后的
* {@link SsoTicketController#issue} 调用(过滤器已保证签发即门禁全过)。
*/
public String issue(String rawToken) {
byte[] random = new byte[32];
secureRandom.nextBytes(random);
String ticket = Base64.getUrlEncoder().withoutPadding().encodeToString(random);
store.put(KEY_PREFIX + ticket, rawToken, ttlSeconds);
return ticket;
}
/**
* 弹票并重新走完整认证。成功返回票内 token 与 clientUserBizId;
* 票无效/过期/已使用、或主体此刻未通过门禁(如解绑、停用)统一返回 null——
* 调用方一律 401,不区分原因。
*/
public TicketSession consumeAndAuthenticate(String ticket) {
if (ticket == null || ticket.trim().isEmpty()) {
return null;
}
String token = store.getAndDelete(KEY_PREFIX + ticket.trim());
if (token == null) {
return null;
}
String clientUserBizId = authenticator.authenticate(token);
if (clientUserBizId == null) {
log.warn("票有效但主体认证未过(解绑/停用/过期),已销毁");
return null;
}
return new TicketSession(token, clientUserBizId);
}
/** 消费成功产物:供前端建会话(token 仅内存,沿用 /c/** 的 Bearer 口径)。 */
public static final class TicketSession {
public final String token;
public final String clientUserBizId;
TicketSession(String token, String clientUserBizId) {
this.token = token;
this.clientUserBizId = clientUserBizId;
}
}
}
package com.yd.csf.api.security;
import org.junit.jupiter.api.Test;
import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult;
import org.springframework.security.oauth2.jwt.Jwt;
import java.time.Instant;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.LinkedHashSet;
import java.util.Map;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* aud 接受集合语义(design D11 / tasks 11.2):多值配置、单值兼容、集合外拒绝。
*/
class ReferrerAudienceValidatorTest {
private static final String WORKBENCH = "6db48077896a73e99c4a";
private static final String MINIAPP = "6ab191c9d64b1afb7238";
private static Jwt jwtWithAud(Object... auds) {
Map<String, Object> headers = new HashMap<>();
headers.put("alg", "RS256");
Map<String, Object> claims = new HashMap<>();
claims.put("sub", "subject-1");
claims.put("aud", Arrays.asList(auds));
Instant now = Instant.now();
return new Jwt("raw.token.value", now, now.plusSeconds(3600), headers, claims);
}
private static boolean validate(Set<String> accepted, Jwt jwt) {
OAuth2TokenValidatorResult result =
ReferrerAuthenticator.audienceValidator(accepted).validate(jwt);
return !result.hasErrors();
}
@Test
void multiValueConfig_acceptsEitherMember() {
Set<String> accepted = ReferrerAuthenticator
.parseAcceptedAudiences(WORKBENCH + "," + MINIAPP);
assertEquals(2, accepted.size());
assertTrue(validate(accepted, jwtWithAud(MINIAPP)), "小程序应用签发的 token 应通过");
assertTrue(validate(accepted, jwtWithAud(WORKBENCH)), "工作台应用签发的 token 应通过");
}
@Test
void singleValueConfig_keepsLegacyContainsSemantics() {
Set<String> accepted = ReferrerAuthenticator.parseAcceptedAudiences(MINIAPP);
assertTrue(validate(accepted, jwtWithAud("other-app", MINIAPP)),
"token aud 数组包含配置值即通过(与旧单值行为一致)");
}
@Test
void foreignAudience_rejected() {
Set<String> accepted = ReferrerAuthenticator
.parseAcceptedAudiences(WORKBENCH + "," + MINIAPP);
assertFalse(validate(accepted, jwtWithAud("a46696d099289b6b526d")),
"接受集合外的应用(如 cffp)签发必须拒绝");
}
@Test
void parse_dropsBlanksAndEmptyIsSkipAll() {
Set<String> accepted = ReferrerAuthenticator.parseAcceptedAudiences(" a , ,b,");
LinkedHashSet<String> expected = new LinkedHashSet<>(Arrays.asList("a", "b"));
assertEquals(expected, accepted);
assertTrue(ReferrerAuthenticator.parseAcceptedAudiences("").isEmpty());
assertTrue(ReferrerAuthenticator.parseAcceptedAudiences(null).isEmpty());
}
@Test
void missingDecoder_whenJwkSetUriEmpty() {
// jwk-set-uri 未配置 → 不建解码器:/c/** 链退化为开发期 mock,票桥 consume 恒拒
ReferrerOidcProperties props = new ReferrerOidcProperties();
org.springframework.security.oauth2.jwt.JwtDecoder decoder =
ReferrerAuthenticator.buildJwtDecoder(props);
org.junit.jupiter.api.Assertions.assertNull(decoder);
}
@Test
void claimsOfValidator_ignoreMissingAud() {
Map<String, Object> headers = new HashMap<>();
headers.put("alg", "RS256");
Map<String, Object> claims = new HashMap<>();
claims.put("sub", "subject-1");
Instant now = Instant.now();
Jwt noAud = new Jwt("raw", now, now.plusSeconds(60), headers, claims);
assertFalse(validate(new LinkedHashSet<>(Collections.singletonList(MINIAPP)), noAud));
}
}
package com.yd.csf.api.security;
import org.junit.jupiter.api.Test;
import java.util.HashMap;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* 票桥生命周期(design D11):签发、消费即焚、一次性重放拒绝、消费端重验失败即销毁。
*/
class SsoTicketServiceTest {
/** 内存票存储(键为完整 key,含前缀) */
static final class MapTicketStore implements ReferrerTicketStore {
final Map<String, String> data = new HashMap<>();
long lastTtlSeconds;
@Override
public void put(String key, String token, long ttlSeconds) {
data.put(key, token);
lastTtlSeconds = ttlSeconds;
}
@Override
public String getAndDelete(String key) {
return data.remove(key);
}
}
/** 固定裁决的 authenticator:不触网、不依赖 Spring 上下文 */
static final class StubAuthenticator extends ReferrerAuthenticator {
String verdict;
StubAuthenticator(String verdict) {
super(new ReferrerOidcProperties(), null);
this.verdict = verdict;
}
@Override
public String authenticate(String rawToken) {
return verdict;
}
}
private static final String RAW_TOKEN = "eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJ4In0.sig";
@Test
void issue_returnsUrlSafeTicket_andStoresTokenWithTtl() {
MapTicketStore store = new MapTicketStore();
SsoTicketService svc = new SsoTicketService(store, new StubAuthenticator("biz-1"), 30);
String ticket = svc.issue(RAW_TOKEN);
assertNotNull(ticket);
assertEquals(43, ticket.length(), "32 字节 base64url 无填充 = 43 字符");
assertTrue(ticket.matches("[A-Za-z0-9_-]+"), "url-safe 字符集");
assertEquals(30, store.lastTtlSeconds);
assertTrue(store.data.containsKey("csf:sso:ticket:" + ticket));
assertEquals(RAW_TOKEN, store.data.get("csf:sso:ticket:" + ticket));
}
@Test
void consume_validTicket_returnsTokenAndIdentity() {
MapTicketStore store = new MapTicketStore();
SsoTicketService svc = new SsoTicketService(store, new StubAuthenticator("client_user_xxx"), 30);
String ticket = svc.issue(RAW_TOKEN);
SsoTicketService.TicketSession session = svc.consumeAndAuthenticate(ticket);
assertNotNull(session);
assertEquals(RAW_TOKEN, session.token);
assertEquals("client_user_xxx", session.clientUserBizId);
}
@Test
void consume_replayIsRejected_burnOnConsume() {
MapTicketStore store = new MapTicketStore();
SsoTicketService svc = new SsoTicketService(store, new StubAuthenticator("biz-1"), 30);
String ticket = svc.issue(RAW_TOKEN);
assertNotNull(svc.consumeAndAuthenticate(ticket));
assertNull(svc.consumeAndAuthenticate(ticket), "同票二次消费必须失败(消费即焚)");
}
@Test
void consume_gateFailsAtConsumeTime_ticketDestroyed() {
MapTicketStore store = new MapTicketStore();
StubAuthenticator gate = new StubAuthenticator("biz-1");
SsoTicketService svc = new SsoTicketService(store, gate, 30);
String ticket = svc.issue(RAW_TOKEN);
gate.verdict = null; // 签发后、消费前被解绑/停用
assertNull(svc.consumeAndAuthenticate(ticket), "消费端重验失败应拒绝");
gate.verdict = "biz-1";
assertNull(svc.consumeAndAuthenticate(ticket), "失败即焚:不放行稍后恢复的主体");
}
@Test
void consume_blankOrUnknownTicket_returnsNull() {
MapTicketStore store = new MapTicketStore();
SsoTicketService svc = new SsoTicketService(store, new StubAuthenticator("biz-1"), 30);
assertNull(svc.consumeAndAuthenticate(null));
assertNull(svc.consumeAndAuthenticate(" "));
assertNull(svc.consumeAndAuthenticate("not-issued-ticket"));
}
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment