OAuth2与OIDC:授权码、PKCE、令牌与撤销窗口
OAuth2描述客户端怎样获得访问资源的授权;OIDC在它上面增加登录身份契约。能验签只回答一部分信任问题:发给谁、由谁签、什么时候有效、有没有被撤销,以及调用者能否操作这个订单,必须分别判断。资源归属的唯一讲解见方法、对象与租户授权;浏览器凭据、CSRF和会话生命周期见认证与会话及浏览器防护。
本页固定 Security7.0.8 / Boot4.0.9 / Framework7.0.10 / JDK21 / Nimbus10.4,查询2026-10-26。Spring Authorization Server已纳入Security7,不能沿用旧独立项目的包名和DSL。旧Security6.3.3过滤链机制证据继续保留。真实三进程实验与下面标注的固定源码推演分开;没有企业IdP、生产TLS、数据库耐久或集群压测声明。
角色、令牌与信任输入
合成场景:alice通过浏览器进入订单客户端,授权服务器AS验证用户并颁发授权码,客户端换取令牌,用access token访问资源服务器RS。四个主体是用户/浏览器、Client、AS、RS;一个产品可以实现多个角色,但它们的信任责任不会合并。
| 对象 | 接收者及用途 | 必须避免的混淆 |
|---|---|---|
| authorization code | 绑定客户端/redirect URI/PKCE的短期一次性兑换凭证 | 不能直接当资源凭证;截获code不等于拥有verifier |
| access token | RS按受众、时效与权限接受 | 可以是JWT,也可以是reference字符串;OAuth不要求必须JWT |
| ID token | OIDC Client验证用户登录、aud通常含client_id | 不是给orders-api的访问凭证;client校验不替代RS校验 |
| refresh token | 客户端向AS更新授权凭证 | 不发给业务RS,不因access撤销就假定必然失效 |
| scope / claim / authority | 请求的授权范围、断言字段、框架转换后的权限 | scope不证明对象所有权;未知claim不是自动可信的业务角色 |
OIDC至少通过issuer、签名、sub、aud/azp、exp/iat与nonce等规则绑定登录响应。UserInfo是另一个受保护端点,其sub应与ID token的sub对应;不能把浏览器随手提交的邮箱当作这个绑定。oauth2Login负责外部身份登录后的本地session,oauth2Client负责持有/选择/刷新授权客户端,oauth2ResourceServer验证入站token;AS负责客户端注册、授权记录、签发、查询和撤销。ID token里有用户信息,也不意味着应该把完整token写日志。
完整配置与三个进程的复现入口
依赖、启动、地址、issuer、redirect与清理都在同一个公开工程中;不需要先申请云账号:
JAVA_HOME=$(/usr/libexec/java_home -v21) python3 labs/oauth2-authorization/run.py
python3 labs/oauth2-authorization/run.py --case oidc --label reproduce-oidc完整依赖(由Boot BOM管理,不手动混搭Security/Nimbus):
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-parent</artifactId><version>4.0.9</version><relativePath/></parent>
<groupId>study</groupId><artifactId>oauth2-authorization</artifactId><version>1</version>
<properties><java.version>21</java.version><maven.compiler.parameters>true</maven.compiler.parameters></properties>
<dependencies>
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-webmvc</artifactId></dependency>
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-security-oauth2-authorization-server</artifactId></dependency>
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-security-oauth2-client</artifactId></dependency>
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-security-oauth2-resource-server</artifactId></dependency>
</dependencies>
<build><plugins><plugin><groupId>org.springframework.boot</groupId><artifactId>spring-boot-maven-plugin</artifactId></plugin></plugins></build>
</project>完整AS定义,包括RegisteredClient、用户源、TokenSettings、授权端点链、浏览器链、JWKSource与token customizer:
package study.oauthlab;
import java.time.Instant;
import java.time.Duration;
import java.util.*;
import java.util.concurrent.atomic.AtomicReference;
import com.nimbusds.jose.*;
import com.nimbusds.jose.crypto.*;
import com.nimbusds.jose.jwk.*;
import com.nimbusds.jose.jwk.gen.*;
import com.nimbusds.jose.jwk.source.JWKSource;
import com.nimbusds.jose.proc.SecurityContext;
import com.nimbusds.jwt.*;
import jakarta.servlet.FilterChain;
import jakarta.servlet.http.*;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.*;
import org.springframework.core.annotation.Order;
import org.springframework.http.MediaType;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.core.userdetails.*;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
import org.springframework.security.oauth2.core.*;
import org.springframework.security.oauth2.core.oidc.OidcScopes;
import org.springframework.security.oauth2.server.authorization.client.*;
import org.springframework.security.oauth2.server.authorization.settings.*;
import org.springframework.security.oauth2.server.authorization.token.*;
import org.springframework.security.oauth2.server.authorization.OAuth2TokenType;
import org.springframework.security.web.*;
import org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint;
import org.springframework.security.web.context.SecurityContextHolderFilter;
import org.springframework.security.web.csrf.CsrfToken;
import org.springframework.security.web.util.matcher.MediaTypeRequestMatcher;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.filter.OncePerRequestFilter;
@Configuration
@Profile("as")
class AuthorizationServer {
@Bean Keys keys() throws Exception { return new Keys(); }
@Bean JWKSource<SecurityContext> jwkSource(Keys keys) {
return (selector, context) -> selector.select(new JWKSet(keys.published()));
}
@Bean RegisteredClientRepository clients(@Value("${lab.client}") String client) {
var interactive = RegisteredClient.withId("synthetic-client")
.clientId("lab-client").clientSecret("{noop}lab-secret")
.clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
.authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN)
.redirectUri(client + "/login/oauth2/code/lab")
.redirectUri("http://127.0.0.1/callback")
.scope(OidcScopes.OPENID).scope(OidcScopes.PROFILE).scope("orders.read").scope("orders.write")
.clientSettings(ClientSettings.builder().requireProofKey(true).requireAuthorizationConsent(false).build())
.tokenSettings(TokenSettings.builder().accessTokenTimeToLive(Duration.ofMinutes(5))
.reuseRefreshTokens(false).build()).build();
var reference = RegisteredClient.withId("synthetic-reference").clientId("reference-client").clientSecret("{noop}reference-secret")
.clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
.authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS).scope("orders.read")
.tokenSettings(TokenSettings.builder().accessTokenFormat(OAuth2TokenFormat.REFERENCE).build()).build();
return new InMemoryRegisteredClientRepository(interactive, reference);
}
@Bean AuthorizationServerSettings settings(@Value("${lab.issuer}") String issuer) {
return AuthorizationServerSettings.builder().issuer(issuer).build();
}
@Bean UserDetailsService users() {
return new InMemoryUserDetailsManager(User.withUsername("alice").password("{noop}lab-password").roles("USER").build());
}
@Bean OAuth2TokenCustomizer<JwtEncodingContext> claims(Keys keys) {
return context -> {
context.getJwsHeader().keyId(keys.active.get());
if (OAuth2TokenType.ACCESS_TOKEN.equals(context.getTokenType()))
context.getClaims().audience(List.of("orders-api")).claim("tenant", "t1");
if ("id_token".equals(context.getTokenType().getValue()) && keys.badNonce)
context.getClaims().claim("nonce", "deliberately-wrong-synthetic-nonce");
};
}
@Bean OAuth2TokenCustomizer<OAuth2TokenClaimsContext> referenceClaims() {
return context -> context.getClaims().audience(List.of("orders-api")).claim("tenant", "t1");
}
@Bean @Order(1) SecurityFilterChain protocol(HttpSecurity http, Keys keys) throws Exception {
http.oauth2AuthorizationServer(as -> { http.securityMatcher(as.getEndpointsMatcher()); as.oidc(Customizer.withDefaults()); })
.authorizeHttpRequests(a -> a.anyRequest().authenticated())
.exceptionHandling(e -> e.defaultAuthenticationEntryPointFor(new LoginUrlAuthenticationEntryPoint("/login"), new MediaTypeRequestMatcher(MediaType.TEXT_HTML)));
http.addFilterBefore(new OncePerRequestFilter() {
protected void doFilterInternal(HttpServletRequest r, HttpServletResponse s, FilterChain c) throws java.io.IOException, jakarta.servlet.ServletException {
if ((r.getRequestURI().equals("/oauth2/jwks") && keys.jwksDown) ||
(r.getRequestURI().equals("/oauth2/introspect") && keys.introspectionDown)) { s.setStatus(503); return; }
c.doFilter(r, s);
}
}, SecurityContextHolderFilter.class);
return http.build();
}
@Bean @Order(2) SecurityFilterChain browser(HttpSecurity http) throws Exception {
http.authorizeHttpRequests(a -> a.requestMatchers("/lab/**").permitAll().anyRequest().authenticated())
.csrf(c -> c.ignoringRequestMatchers("/lab/control", "/lab/mint"))
.formLogin(Customizer.withDefaults());
return http.build();
}
static class Keys {
final RSAKey a; final RSAKey b; final RSAKey foreign;
final AtomicReference<String> active = new AtomicReference<>("A");
volatile String publication = "A";
volatile boolean jwksDown, introspectionDown, badNonce;
Keys() throws Exception {
a = new RSAKeyGenerator(2048).keyID("A").generate();
b = new RSAKeyGenerator(2048).keyID("B").generate();
foreign = new RSAKeyGenerator(2048).keyID("foreign").generate();
}
List<JWK> published() { return switch (publication) { case "AB" -> List.of(a, b); case "B" -> List.of(b); default -> List.of(a); }; }
}
@RestController @Profile("as") static class Controls {
final Keys keys; final String control; final String issuer;
Controls(Keys k, @Value("${LAB_CONTROL}") String c, @Value("${lab.issuer}") String i) {keys=k; control=c; issuer=i;}
void check(String value) { if (!java.security.MessageDigest.isEqual(control.getBytes(java.nio.charset.StandardCharsets.UTF_8), value.getBytes(java.nio.charset.StandardCharsets.UTF_8))) throw new org.springframework.web.server.ResponseStatusException(org.springframework.http.HttpStatus.FORBIDDEN); }
@GetMapping("/lab/csrf") Map<String,String> csrf(CsrfToken t) { return Map.of("token",t.getToken(),"parameterName",t.getParameterName()); }
@PostMapping("/lab/control") Map<String,Object> control(@RequestHeader("X-Lab-Control") String secret, @RequestParam Map<String,String> p) {
check(secret);
if (p.containsKey("publish")) keys.publication=p.get("publish");
if (p.containsKey("active")) keys.active.set(p.get("active"));
if (p.containsKey("jwksDown")) keys.jwksDown=Boolean.parseBoolean(p.get("jwksDown"));
if (p.containsKey("introspectionDown")) keys.introspectionDown=Boolean.parseBoolean(p.get("introspectionDown"));
if (p.containsKey("badNonce")) keys.badNonce=Boolean.parseBoolean(p.get("badNonce"));
return Map.of("publication", keys.publication, "active", keys.active.get());
}
@PostMapping("/lab/mint") Map<String,String> mint(@RequestHeader("X-Lab-Control") String secret, @RequestParam(defaultValue="valid") String kind) throws Exception {
check(secret);
Instant now=Instant.now();
var c=new JWTClaimsSet.Builder().issuer(kind.equals("issuer")?"https://wrong.invalid":issuer)
.subject("alice").audience(kind.equals("audience")?"other-api":"orders-api")
.issueTime(Date.from(now)).claim("tenant", "t1").claim("scope",kind.equals("scope")?"profile":"orders.read orders.write")
.expirationTime(Date.from(now.plusSeconds(kind.equals("expired")?-120:300)));
if(kind.equals("no-exp")) c.expirationTime(null);
if(kind.equals("future")) c.notBeforeTime(Date.from(now.plusSeconds(300)));
if(kind.equals("no-tenant")) c.claim("tenant",null);
if(kind.equals("tenant")) c.claim("tenant","t2");
String kid = kind.equals("unknown-kid")?"unknown":keys.active.get();
JWSAlgorithm alg=kind.equals("algorithm")?JWSAlgorithm.RS512:JWSAlgorithm.RS256;
SignedJWT token=new SignedJWT(new JWSHeader.Builder(alg).type(JOSEObjectType.JWT).keyID(kid).build(),c.build());
RSAKey signer=kind.equals("signature")?keys.foreign:(keys.active.get().equals("A")?keys.a:keys.b);
token.sign(new RSASSASigner(signer));
return Map.of("token",token.serialize());
}
}
}完整Client定义,包括discovery、registration、授权请求resolver、登录失败响应与OAuth2 RestClient:
package study.oauthlab;
import java.util.*;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.*;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.oauth2.client.*;
import org.springframework.security.oauth2.client.registration.*;
import org.springframework.security.oauth2.client.web.*;
import org.springframework.security.oauth2.client.web.client.OAuth2ClientHttpRequestInterceptor;
import org.springframework.security.oauth2.core.*;
import org.springframework.security.oauth2.core.oidc.user.OidcUser;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.client.RestClient;
@Configuration @Profile("client")
class OidcClient {
@Bean ClientRegistrationRepository registrations(@Value("${lab.issuer}") String issuer) {
return new InMemoryClientRegistrationRepository(ClientRegistrations.fromIssuerLocation(issuer)
.registrationId("lab").clientId("lab-client").clientSecret("lab-secret")
.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
.redirectUri("{baseUrl}/login/oauth2/code/{registrationId}")
.scope("openid","profile","orders.read","orders.write").build());
}
@Bean SecurityFilterChain client(HttpSecurity http,ClientRegistrationRepository repo) throws Exception {
var resolver=new DefaultOAuth2AuthorizationRequestResolver(repo,"/oauth2/authorization");
resolver.setAuthorizationRequestCustomizer(OAuth2AuthorizationRequestCustomizers.withPkce());
http.authorizeHttpRequests(a -> a.requestMatchers("/error").permitAll().anyRequest().authenticated())
.oauth2Login(o -> o.authorizationEndpoint(a -> a.authorizationRequestResolver(resolver))
.defaultSuccessUrl("/client/me",true).failureHandler((r,s,e) -> {s.setStatus(401);s.setContentType("application/json");s.getWriter().write("{\"error\":\"oauth2_login_failed\"}");}))
.oauth2Client(c -> {});
return http.build();
}
@Bean RestClient resourceClient(OAuth2AuthorizedClientManager manager) {
var interceptor=new OAuth2ClientHttpRequestInterceptor(manager);
interceptor.setClientRegistrationIdResolver(request -> "lab");
return RestClient.builder().requestInterceptor(interceptor).build();
}
@RestController @Profile("client") static class Endpoints {
final RestClient http; final String rs;
Endpoints(RestClient h,@Value("${lab.rs}") String rs) {http=h;this.rs=rs;}
@GetMapping("/client/me") Map<String,Object> me(@AuthenticationPrincipal OidcUser user) {
return Map.of("name",user.getSubject(),"issuer",user.getIssuer().toString(),"idTokenValidated",true);
}
@GetMapping("/client/resource") String resource() {return http.get().uri(rs+"/jwt/read").retrieve().body(String.class);}
}
}完整RS及方法/对象规则:
package study.oauthlab;
import java.time.Duration;
import java.util.*;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.*;
import org.springframework.core.annotation.Order;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.core.Authentication;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.oauth2.core.*;
import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm;
import org.springframework.security.oauth2.jwt.*;
import org.springframework.security.oauth2.server.resource.introspection.*;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.config.ObjectPostProcessor;
import org.springframework.security.authentication.AuthenticationServiceException;
import org.springframework.security.oauth2.server.resource.web.authentication.BearerTokenAuthenticationFilter;
import org.springframework.security.oauth2.server.resource.web.BearerTokenAuthenticationEntryPoint;
import org.springframework.stereotype.Service;
import org.springframework.web.bind.annotation.*;
@Configuration @Profile("rs") @EnableMethodSecurity
class ResourceServer {
@Bean JwtDecoder decoder(@Value("${lab.issuer}") String issuer) {
var decoder=NimbusJwtDecoder.withJwkSetUri(issuer+"/oauth2/jwks").jwsAlgorithm(SignatureAlgorithm.RS256).build();
var clock=new JwtTimestampValidator(Duration.ZERO);
clock.setAllowEmptyExpiryClaim(false);
decoder.setJwtValidator(new DelegatingOAuth2TokenValidator<>(JwtValidators.createDefaultWithIssuer(issuer), clock,
new JwtAudienceValidator("orders-api"),
jwt -> jwt.getClaimAsString("tenant") != null && Set.of("t1","t2").contains(jwt.getClaimAsString("tenant"))
? OAuth2TokenValidatorResult.success() : OAuth2TokenValidatorResult.failure(new OAuth2Error("invalid_token","trusted tenant required",null))));
return decoder;
}
@Bean OpaqueTokenIntrospector introspector(@Value("${lab.issuer}") String issuer) {
var remote=SpringOpaqueTokenIntrospector.withIntrospectionUri(issuer+"/oauth2/introspect")
.clientId("lab-client").clientSecret("lab-secret").build();
return token -> {
var principal=remote.introspect(token);
Object aud=principal.getAttribute("aud");
if(!(aud instanceof Collection<?> values) || !values.contains("orders-api"))
throw new BadOpaqueTokenException("wrong audience");
return principal;
};
}
@Bean @Order(1) SecurityFilterChain jwt(HttpSecurity http) throws Exception {
http.securityMatcher("/jwt/**").sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
// This API accepts only explicit Authorization Bearer headers; no browser automatic credentials.
.csrf(c -> c.disable()).authorizeHttpRequests(a -> a.requestMatchers("/jwt/admin").hasAuthority("SCOPE_admin")
.requestMatchers("/jwt/read").hasAuthority("SCOPE_orders.read").anyRequest().authenticated())
.oauth2ResourceServer(o -> o.jwt(j -> {}).withObjectPostProcessor(failureResponses()));
return http.build();
}
@Bean @Order(2) SecurityFilterChain opaque(HttpSecurity http) throws Exception {
http.securityMatcher("/opaque/**").sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.csrf(c -> c.disable()).authorizeHttpRequests(a -> a.anyRequest().hasAuthority("SCOPE_orders.read"))
.oauth2ResourceServer(o -> o.opaqueToken(t -> {}).withObjectPostProcessor(failureResponses()));
return http.build();
}
static ObjectPostProcessor<BearerTokenAuthenticationFilter> failureResponses() {
return new ObjectPostProcessor<BearerTokenAuthenticationFilter>() {
public <O extends BearerTokenAuthenticationFilter> O postProcess(O filter) {
filter.setAuthenticationFailureHandler((request,response,error) -> {
if (error instanceof AuthenticationServiceException) {
response.setStatus(503);
response.setContentType("application/json");
response.getWriter().write("{\"error\":\"identity_provider_unavailable\"}");
} else new BearerTokenAuthenticationEntryPoint().commence(request,response,error);
});
return filter;
}
};
}
@Bean @Order(3) SecurityFilterChain deny(HttpSecurity http) throws Exception {
http.authorizeHttpRequests(a -> a.anyRequest().denyAll()); return http.build();
}
@Bean Ownership ownership() { return new Ownership(); }
public static class Ownership {
// Synthetic server-side records; tenant/user come from validated authentication, never caller authority.
final Map<String,List<String>> records=Map.of("own",List.of("t1","alice"),"other",List.of("t1","bob"),"foreign",List.of("t2","alice"));
public boolean allowed(Authentication auth,String id,String tenant) {
if(!(auth instanceof org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken jwt)) return false;
String trustedTenant=jwt.getToken().getClaimAsString("tenant");
return Objects.equals(tenant,trustedTenant) && Objects.equals(records.get(id),List.of(trustedTenant,auth.getName()));
}
}
@Service @Profile("rs") public static class Orders {
@PreAuthorize("hasAuthority('SCOPE_orders.write')")
public Map<String,String> write() { return Map.of("result","authorized-write"); }
@PreAuthorize("hasAuthority('SCOPE_orders.read') and @ownership.allowed(authentication,#id,#tenant)")
public Map<String,String> read(String id,String tenant) { return Map.of("id",id,"tenant",tenant,"result","owned"); }
}
@RestController @Profile("rs") static class Endpoints {
final Orders orders;
Endpoints(Orders orders) {this.orders=orders;}
@GetMapping({"/jwt/read","/opaque/read","/jwt/admin"}) Map<String,Object> read(Authentication a) {
return Map.of("name",a.getName(),"authorities",a.getAuthorities().stream().map(Object::toString).toList());
}
@GetMapping("/jwt/method") Map<String,String> method() {return orders.write();}
@GetMapping("/jwt/orders/{id}") Map<String,String> order(@PathVariable String id,@RequestParam String tenant) { return orders.read(id,tenant); }
}
}应用入口和配置文件、runner同目录发布。runner先选三个不同随机loopback端口,再将本次AS issuer、Client callback、RS地址注入三个独立JVM;AS_SESSION与CLIENT_SESSION避免同域跨端口cookie冲突。AS/Client保留浏览器CSRF;本实验RS只用显式Bearer header,没有cookie/session/Basic自动认证,因此API显式关闭CSRF,不能迁移成“无状态API都不需CSRF”。
交互客户端同时使用secret和PKCE;reference-client使用独立secret与client_credentials,主体是机器而非alice。账号、secret与{noop}只服务本地合成演示;真实身份存储、secret轮换、授权/consent耐久要另设。AS RSA密钥每进程临时生成,不能把重启后旧token全失效当作正确的生产轮换方案。
授权码与PKCE的状态机
Client生成高熵verifier,发送BASE64URL(SHA256(verifier))作为S256 challenge,保存授权请求与state、OIDC nonce;浏览器跳AS。AS完成用户认证与授权,绑定client、精确redirect URI、challenge、scope后返回code和state。Client核对保存请求和state,再以code、相同redirect、verifier及适用客户端认证调用token端点。AS验证这些绑定、消费一次性code后签发令牌;Client再校验ID token/nonce,建立自己的session。
PKCE防止截获code的攻击者独立兑换,client secret认证客户端,两者责任不同。state绑定浏览器授权响应,nonce绑定OIDC认证响应,PKCE绑定code兑换;三者不能互相代替。不得将任意URL当redirect URI,或把verifier放回授权URL。AS登录本身仍需CSRF,PKCE不保护所有浏览器表单。
原码:7.0.8授权请求的PKCE检查。
private static void validateCodeChallenge(
OAuth2AuthorizationCodeRequestAuthenticationContext authenticationContext) {
OAuth2AuthorizationCodeRequestAuthenticationToken authorizationCodeRequestAuthentication = authenticationContext
.getAuthentication();
RegisteredClient registeredClient = authenticationContext.getRegisteredClient();
// code_challenge (REQUIRED for public clients) - RFC 7636 (PKCE)
String codeChallenge = (String) authorizationCodeRequestAuthentication.getAdditionalParameters()
.get(PkceParameterNames.CODE_CHALLENGE);
if (StringUtils.hasText(codeChallenge)) {
String codeChallengeMethod = (String) authorizationCodeRequestAuthentication.getAdditionalParameters()
.get(PkceParameterNames.CODE_CHALLENGE_METHOD);
if (!StringUtils.hasText(codeChallengeMethod) || !"S256".equals(codeChallengeMethod)) {
throwError(OAuth2ErrorCodes.INVALID_REQUEST, PkceParameterNames.CODE_CHALLENGE_METHOD, PKCE_ERROR_URI,
authorizationCodeRequestAuthentication, registeredClient);
}
}
else if (registeredClient.getClientSettings().isRequireProofKey()) {
throwError(OAuth2ErrorCodes.INVALID_REQUEST, PkceParameterNames.CODE_CHALLENGE, PKCE_ERROR_URI,
authorizationCodeRequestAuthentication, registeredClient);
}有challenge但method不是S256会失败;没有challenge且注册要求PKCE也失败。该版本AS ClientSettings及ClientRegistration的授权码客户端默认requireProofKey已为true,包括confidential client;本文仍在AS显式true、Client显式withPkce表达策略,不能把旧版默认false套过来。public client即使没有secret也必须保护code兑换。
原码:默认OIDC nonce的存储与呈现。
private static void applyNonce(OAuth2AuthorizationRequest.Builder builder) {
try {
String nonce = DEFAULT_SECURE_KEY_GENERATOR.generateKey();
String nonceHash = createHash(nonce);
builder.attributes((attrs) -> attrs.put(OidcParameterNames.NONCE, nonce));
builder.additionalParameters((params) -> params.put(OidcParameterNames.NONCE, nonceHash));
}
catch (NoSuchAlgorithmException ex) {
}
}
private static String createHash(String value) throws NoSuchAlgorithmException {
MessageDigest md = MessageDigest.getInstance("SHA-256");
byte[] digest = md.digest(value.getBytes(StandardCharsets.US_ASCII));
return Base64.getUrlEncoder().withoutPadding().encodeToString(digest);
}本地attributes保存raw nonce,线上additionalParameters保存其SHA-256表示;OIDC provider按这套关系核对。自定义resolver/repository若丢失本地绑定,不能再声称默认校验仍完整。错state可能在repository取不到请求时就失败,不保证统一走provider的某个内部错误码。
实跑:缺challenge/plain=>invalid_request;错verifier=>invalid_grant;正常code换token,重放code=>invalid_grant。失败兑换或重放可使关联授权失效,恢复测试重新授权,不拿旧grant当正常控制。真实Client错state401、AS真实签名但错nonce的ID token401,失败后读取身份仍跳登录;解除故障后新授权成功。
Client/Login与对外HTTP调用
ClientRegistration保存provider、端点、client authentication、redirect和scope;ClientRegistrationRepository按registrationId选择注册。OAuth2AuthorizedClient把registration、principalName、access/refresh状态关联;Repository/Service承担请求或长期存储,Manager借Provider执行authorization_code、refresh_token或client_credentials等授权过程。选择键至少考虑registration与principal,不能让所有用户共享一个“最新token”。
DefaultOAuth2AuthorizedClientManager适用于Servlet请求;后台任务使用与请求无关的service型manager并明确机器/用户主体。refresh支持是provider配置与refresh存在的结果,不是每次请求都刷新。AS返回invalid_grant时,应清理失效授权客户端、触发受控重新授权或失败;不能无限重放refresh。Manager刷新与AS的refresh轮换是两层,本实验实际测了AS旋转/撤销,没有等待RestClient manager自动到期刷新。
入站oauth2Login的authority映射可通过GrantedAuthoritiesMapper或受控OidcUserService处理;必须限定哪个issuer的哪个claim可以映射哪些本地权限,不能把任意groups字符串直接变成ADMIN。普通OAuth UserInfo与OIDC ID token不是同一校验契约。opaque机器主体的权限也不自动等同某个用户。
实验中的OAuth2ClientHttpRequestInterceptor由manager为固定registration选择token,再由固定baseURL调用RS。别把带自动bearer的RestClient暴露给任意用户输入URL;否则可能把有效token发到攻击者地址。HTTP接口还需把@ClientRegistrationId交给处理器,而非仅写一个注解:
// Security7.0.8 + Framework7 的HTTP Service分组配置示例;固定文档契约,非本票实跑。
@Configuration(proxyBeanMethods = false)
@ImportHttpServices(types = OrdersApi.class)
class OrdersHttpServices {
@Bean OAuth2RestClientHttpServiceGroupConfigurer oauth(OAuth2AuthorizedClientManager manager) {
return OAuth2RestClientHttpServiceGroupConfigurer.from(manager);
}
@Bean RestClientHttpServiceGroupConfigurer addresses() {
return groups -> groups.forEachClient((group, builder) ->
builder.baseUrl("https://orders.example.invalid"));
}
}
@ClientRegistrationId("lab")
interface OrdersApi {
@GetExchange("/orders") String list();
}这里manager需像完整Client工程一样由应用安全配置提供,group configurer负责装interceptor与ClientRegistrationIdProcessor;.invalid只是不会误访问真实企业的配置示例,未执行网络请求。若手工HttpServiceProxyFactory则要自己接processor和interceptor;WebClient换对应OAuth filter与service group configurer。普通HTTP接口/超时机制由HTTP客户端正文承接。
其他grant必须先定义身份与委托:client_credentials无最终用户;JWT bearer用客户端持有的断言并限制issuer/aud/重放;token exchange需要验证subject/actor、目标audience与允许委托范围,不能把传入token随便换成管理员token。默认不应恢复password/implicit等旧便利流程。扩展grant需注册provider/客户端grant和允许scope,不能只改一个grant_type字符串。
JWT验证是一组独立约束
RS先按允许算法和JWK候选验签,再按policy验证issuer、audience、时间、类型和业务必需claim,最后转换principal/authorities。未验证payload只可用作受控路由提示;它本身不是可信身份。RS至少应精确固定issuer、允许算法、资源audience及时间策略。自定义setJwtValidator是替换入口,应组合保留应有规则,不能只装audience后丢失issuer/时间检查。
原码:默认JWT validator的组成。
public static OAuth2TokenValidator<Jwt> createDefaultWithIssuer(String issuer) {
return createDefaultWithValidators(new JwtIssuerValidator(issuer));
}
/**
* <p>
* Create a {@link Jwt} Validator that contains all standard validators.
* </p>
* <p>
* User's wanting to leverage the defaults plus additional validation can add the
* result of this method to {@code DelegatingOAuth2TokenValidator} along with the
* additional validators.
* </p>
* @return - a delegating validator containing all standard validators as well as any
* supplied
*/
public static OAuth2TokenValidator<Jwt> createDefault() {
return new DelegatingOAuth2TokenValidator<>(Arrays.asList(JwtTypeValidator.jwt(), new JwtTimestampValidator(),
new X509CertificateThumbprintValidator(
X509CertificateThumbprintValidator.DEFAULT_X509_CERTIFICATE_SUPPLIER)));
}
/**
* <p>
* Create a {@link Jwt} default validator with standard validators and additional
* validators.
* </p>
* @param validators additional validators
* @return - a delegating validator containing all standard validators with additional
* validators
* @since 6.3
*/
public static OAuth2TokenValidator<Jwt> createDefaultWithValidators(List<OAuth2TokenValidator<Jwt>> validators) {
Assert.notEmpty(validators, "validators cannot be null or empty");
List<OAuth2TokenValidator<Jwt>> tokenValidators = new ArrayList<>(validators);
X509CertificateThumbprintValidator x509CertificateThumbprintValidator = CollectionUtils
.findValueOfType(tokenValidators, X509CertificateThumbprintValidator.class);
if (x509CertificateThumbprintValidator == null) {
tokenValidators.add(0, new X509CertificateThumbprintValidator(
X509CertificateThumbprintValidator.DEFAULT_X509_CERTIFICATE_SUPPLIER));
}
JwtTimestampValidator jwtTimestampValidator = CollectionUtils.findValueOfType(tokenValidators,
JwtTimestampValidator.class);
if (jwtTimestampValidator == null) {
tokenValidators.add(0, new JwtTimestampValidator());
}
JwtTypeValidator typeValidator = CollectionUtils.findValueOfType(tokenValidators, JwtTypeValidator.class);
if (typeValidator == null) {
tokenValidators.add(0, JwtTypeValidator.jwt());
}
return new DelegatingOAuth2TokenValidator<>(tokenValidators);createDefault不等于强制iss/aud/exp/nbf齐全;createDefaultWithIssuer加issuer,audience另配。时间validator默认60秒skew,默认允许缺exp/nbf;本实验额外setAllowEmptyExpiryClaim(false)并以Duration.ZERO叠加,要求exp且过期/未来nbf立即按该策略拒绝,但没有要求nbf必须存在,也没把iat当所有token的自动新鲜度限制。
原码:Nimbus不同builder路径。
public static JwkSetUriJwtDecoderBuilder withIssuerLocation(String issuer) {
return new JwkSetUriJwtDecoderBuilder((rest) -> {
Map<String, Object> configuration = JwtDecoderProviderConfigurationUtils
.getConfigurationForIssuerLocation(issuer, rest);
JwtDecoderProviderConfigurationUtils.validateIssuer(configuration, issuer);
return configuration.get("jwks_uri").toString();
}, JwtDecoderProviderConfigurationUtils::getJWSAlgorithms)
.validator(JwtValidators.createDefaultWithIssuer(issuer));
}
/**
* Use the given <a href="https://tools.ietf.org/html/rfc7517#section-5">JWK Set</a>
* uri.
* @param jwkSetUri the JWK Set uri to use
* @return a {@link JwkSetUriJwtDecoderBuilder} for further configurations
*/
public static JwkSetUriJwtDecoderBuilder withJwkSetUri(String jwkSetUri) {
return new JwkSetUriJwtDecoderBuilder(jwkSetUri);
}直接withJwkSetUri默认RS256,但它只给取钥地址,不自动证明期望issuer;withIssuerLocation在7.0.8会装issuer validator,算法可从JWKS发现。本文显式RS256,不能据“默认常见算法”替代白名单。Boot4.0.9的issuer-only与direct jwk-set-uri路径对jws-algorithms配置的应用还有差异;选型时以实际builder/定制器验证,不从YAML属性存在推断生效。
JwtAudienceValidator("orders-api")检查aud包含目标值,不拒绝所有额外aud;Boot多audience列表也不是要求同时包含全部。generic JWT默认typ策略不是完整RFC9068 Access Token profile,若采用at+jwt应显式选匹配profile与必需claim,不只关闭一层type检查。scope默认常映射成SCOPE_...,角色claim映射、大小写与前缀需明确;有效签名与scope都不能替代订单归属。
实跑每个坏输入都使用真实RSA签名并保留其他主要约束:RS512、错签名、错iss、错aud、过期、缺exp、未来nbf、未知kid、缺tenant均401,随后正确token200。错算法使用未声明固定alg的RSA JWK配真实RS512签名,避免仅因JWK元数据不匹配得到假阴性。ID token在JWT RS也401,但它可能同时缺资源aud和tenant,单独wrong-aud反例才隔离资源受众规则。
Opaque与introspection的远端权威
opaque描述RS不依赖本地解码的使用方式,token本身可以是reference字符串,也可以碰巧是JWT。RS使用受信AS introspection地址与自己的客户端凭据提交token,先得到active,再对资源policy中的aud/scope等约束作判断。不要允许用户提供introspection URL;客户端凭据和token都只能通过受保护通道传送,生产应TLS。
原码:默认introspector对active的处理。
private ResponseEntity<Map<String, Object>> makeRequest(RequestEntity<?> requestEntity) {
try {
return this.restOperations.exchange(requestEntity, STRING_OBJECT_MAP);
}
catch (Exception ex) {
throw new OAuth2IntrospectionException(ex.getMessage(), ex);
}
}
private Map<String, Object> adaptToNimbusResponse(ResponseEntity<Map<String, Object>> responseEntity) {
if (responseEntity.getStatusCode() != HttpStatus.OK) {
throw new OAuth2IntrospectionException(
"Introspection endpoint responded with " + responseEntity.getStatusCode());
}
Map<String, Object> claims = responseEntity.getBody();
// relying solely on the authorization server to validate this token (not checking
// 'exp', for example)
if (claims == null) {
return Collections.emptyMap();
}
boolean active = (boolean) claims.compute(OAuth2TokenIntrospectionClaimNames.ACTIVE, (k, v) -> {
if (v instanceof String) {
return Boolean.parseBoolean((String) v);
}
if (v instanceof Boolean) {
return v;
}
return false;
});
if (!active) {
this.logger.trace("Did not validate token since it is inactive");
throw new BadOpaqueTokenException("Provided token isn't active");
}
return claims;默认实现每次introspect发远程请求,不自行重做JWT完整exp/nbf/iss/aud策略;AS负责active是否仍成立。本文装饰器进一步检查aud包含orders-api;固定端点是该模式的信任锚点。若代理或decorator缓存结果,即使Spring实现不缓存,撤销也不再必然下一请求可见。
本实验真实颁发REFERENCE格式机器token,远程200、本地JWT链401、AS撤销后远程401;另用真实AS登记的ID token,先确认active=true且aud不含orders-api,再在opaque RS得到401,证明aud规则实际到达。不要用/lab/mint未登记的合成JWT证明这一规则,那会先因inactive失败。
基础设施故障与无效token不同:active=false或aud不符映射invalid bearer401;远程503会成为OAuth2IntrospectionException,再包装AuthenticationServiceException。默认failure handler可能rethrow,容器error分派再命中其他链,最终状态取决于应用。实验两资源链显式把service异常映射503,避免伪装成“用户没有权限”;实际503→解除故障200→撤销后401均验证。不能失败时降级为信任缓存外的任意身份。
撤销、轮换与故障恢复
原码:AS撤销先查token并核对客户端,再保存失效状态。
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
OAuth2TokenRevocationAuthenticationToken tokenRevocationAuthentication = (OAuth2TokenRevocationAuthenticationToken) authentication;
OAuth2ClientAuthenticationToken clientPrincipal = OAuth2AuthenticationProviderUtils
.getAuthenticatedClientElseThrowInvalidClient(tokenRevocationAuthentication);
RegisteredClient registeredClient = clientPrincipal.getRegisteredClient();
OAuth2Authorization authorization = this.authorizationService
.findByToken(tokenRevocationAuthentication.getToken(), null);
if (authorization == null) {
if (this.logger.isTraceEnabled()) {
this.logger.trace("Did not authenticate token revocation request since token was not found");
}
// Return the authentication request when token not found
return tokenRevocationAuthentication;
}
if (!registeredClient.getId().equals(authorization.getRegisteredClientId())) {
throw new OAuth2AuthenticationException(OAuth2ErrorCodes.INVALID_CLIENT);
}
OAuth2Authorization.Token<OAuth2Token> token = authorization.getToken(tokenRevocationAuthentication.getToken());
authorization = OAuth2Authorization.from(authorization).invalidate(token.getToken()).build();
this.authorizationService.save(authorization);
if (this.logger.isTraceEnabled()) {
this.logger.trace("Saved authorization with revoked token");
// This log is kept separate for consistency with other providers
this.logger.trace("Authenticated token revocation request");
}
return new OAuth2TokenRevocationAuthenticationToken(token.getToken(), clientPrincipal);撤销端点对未知token也可成功返回;HTTP200不证明token曾经有效。7.0.8撤销refresh会在同授权记录中连带失效access等相关状态;只撤access不意味着refresh已撤。纯本地JWT decoder不查询AuthorizationService,所以同一JWT在AS撤销后,introspection401而本地仍可200直到exp或额外阻断策略生效。Client session注销、OP session注销、access撤销、refresh撤销、JWK退休分别处理。
密钥轮换先发布A+B、开始用B签发,保留A覆盖旧token剩余寿命与观测窗口,再撤A。默认Nimbus10.4有效缓存可以按kid匹配;无匹配可在TTL前触发刷新。同kid错签名不等于未匹配,不应把换钥复用kid的失败当作正确刷新策略。默认缓存5分钟不是“旧钥至少有效5分钟”,也不是所有节点同步失效。提供自己的Spring Cache会改变外层缓存机制;ConcurrentMapCache不会凭空获得5分钟TTL,缓存invalidate还可能影响共享该Cache的其他key集。
真实顺序:A200;发布A+B并签B,B触发刷新后200且A200;直连JWKS故障503时已缓存B200,未知kid取钥503;解除故障直连200。发布仅B后暖缓存A仍200,再显式用未知kid触发刷新,A401/B200。没有自然等待完整TTL或全节点测试。早期故障filter排在JWK端点之后,旧“未知kid401”未证明宕机;修正为端点前注入并直连验证后才形成最终证据。
OIDC退出有三种配置责任:默认logout仅清本地session;RP initiated要用OidcClientInitiatedLogoutSuccessHandler(registrations)并设置已登记postLogoutRedirectUri,从受信discovery取end_session_endpoint;OP back-channel使用http.oidcLogout(l -> l.backChannel(withDefaults()))并验证logout token的签名、iss/aud、事件与sid/sub映射。应用使用自定义cookie名时,back-channel handler也需匹配实际session cookie/registry;多节点还需共享映射。它们都不自动撤销所有access/refresh。本票讲配置与失败时序,未声称真实RP/back-channel退出已跑。
Bearer、多个issuer与DPoP
Bearer凭证谁持有谁可使用。默认只接受Authorization header是便于约束泄露面的策略,query token可能进入访问日志/历史/referrer,body token还受方法与content-type限制;不要为“兼容”无条件开放这两种输入。向下游传播需要明确目标audience,不能把A服务的token无条件转发给B。
多issuer的安全顺序是“未验签iss仅作提示→受信任allowlist/map→对应manager→验签/精确issuer/aud/时间→业务tenant授权”。不能把任意iss喂给discovery造成SSRF与信任扩张。JwtIssuerAuthenticationManagerResolver.fromTrustedIssuers(...)可以限制issuer,但它的默认decoder不自动补你的audience、mandatory exp或tenant;需要这些规则时为每个受信issuer建立完整decoder/provider,再按受控map选择,最后仍在数据层检查租户。
DPoP把access token绑定客户端持有的私钥;请求同时携Authorization: DPoP <access>和DPoP: <proof JWT>。proof需要验证非对称签名与公钥、typ=dpop+jwt、htm/htu、iat/jti及防重放;资源请求还需ath匹配access token哈希、access的cnf.jkt匹配proof公钥指纹。复制access而没有私钥不能生成匹配proof,但持有私钥的恶意客户端、XSS或错误资源授权不会因此消失。
7.0.8在DPoP类可用时由资源服务器configurer接入相应链,不能编造旧式.dPoP()开关;完整底层仍需正确JWT/opaque token manager。原码:
BearerTokenAuthenticationFilter filter = new BearerTokenAuthenticationFilter(resolver, converter);
filter.setAuthenticationEntryPoint(this.authenticationEntryPoint);
filter.setSecurityContextHolderStrategy(getSecurityContextHolderStrategy());
filter = postProcess(filter);
http.addFilter(filter);
if (dPoPAuthenticationAvailable) {
DPoPAuthenticationConfigurer<H> dPoPAuthenticationConfigurer = new DPoPAuthenticationConfigurer<>();
dPoPAuthenticationConfigurer.withObjectPostProcessor(
(ObjectPostProcessor<Object>) OAuth2ResourceServerConfigurer.this::postProcess);
dPoPAuthenticationConfigurer.configure(http);
}
OAuth2ProtectedResourceMetadataFilter protectedResourceMetadataFilter = new OAuth2ProtectedResourceMetadataFilter();
if (this.protectedResourceMetadataConfigurer.protectedResourceMetadataCustomizer != null) {防重放validator需要存已用jti并在适用窗口淘汰;验证iat不能替代“已使用过”状态。单机cache不能证明跨节点防重放,cache满/写入失败应拒绝或明确故障策略,不能悄悄允许。若由AS支持DPoP,token端点也要验证proof并签入cnf;在RS仅校验proof而不验证token绑定,会允许攻击者拿自己的key替被盗token作proof。改变HTTP method/URI、换token或跨节点重放同jti是必须另验的失败条件;本票未运行DPoP,不能把Bearer实验当作其通过。
Protected Resource Metadata(RFC9728)用于公开资源标识、受信AS、支持scope等发现信息;7.0.8资源configurer会安装对应filter。metadata不授予权限,公开它不等于允许匿名业务。不要把任意metadata里的AS直接加入trust list;发布外部resource URL时还要核对代理规范化和部署地址。实际metadata端点与DPoP均不在七组实跑声明中。
授权服务器的端点与状态责任
AS的RegisteredClientRepository保存client认证、redirect、grant、scope及ClientSettings;OAuth2AuthorizationService保存code/token/metadata/失效状态,ConsentService保存用户同意。TokenSettings定义TTL、格式、refresh复用等策略,Generator/Customizer负责按token类型生成claim,不能把ID token和access audience统一覆盖。内存实现便于隔离复现,重启丢失授权/撤销/同意状态是明确限制;生产JDBC实现还需事务、加密/密钥权限和多节点一致性。
| 端点族 | 配置与信任输入 | 失败与恢复责任 |
|---|---|---|
| authorize / token | 精确redirect/client、grant/scope、PKCE;token端点按client authentication选择Provider | code错误或复用拒绝;重新授权而非无限重试;本票真实运行 |
| introspect / revoke / JWK | 受信AS地址、合法client、token登记状态;JWK只公开公钥 | 无效/撤销/未知区分;JWKS轮换/缓存与基础设施故障分别观察;本票真实运行 |
| PAR | 客户端先认证推送完整授权参数,AS保存短期request_uri,浏览器随后引用 | 不让浏览器另改已绑定client/redirect/scope;过期/跨client引用失败,重新推送 |
| device authorization / verification | 注册device_code grant;机器取device_code/user_code,用户在可信验证页完成授权,机器按interval轮询 | pending不是成功;slow_down需延后,过期/拒绝终止,绑定正确client防钓鱼;不能把用户码当token |
| OIDC provider configuration / UserInfo | 启用.oidc(withDefaults());issuer metadata与ID token契约;UserInfo需有效授权token | 不因能发现配置就信任任意issuer;sub不匹配拒绝身份拼接 |
| OIDC dynamic registration | 按需显式启用client registration端点与适当初始授权/注册策略,严格校验redirect/grant | 不能开匿名“任意callback”注册;bootstrap credential被盗要撤注册能力并审计所建client |
| OIDC logout | 分开RP本地session、OP session、back-channel sid/sub关联和token撤销 | 一个客户端退出不自动清理其他会话与token;失败按状态对象恢复 |
这些扩展配置从完整AS的端点configurer继续定制,注册grant/客户端策略与端点认证必须同时匹配;只给一个endpoint URL不是完整接入。PAR、device、动态注册、JWT bearer、token exchange及OIDC logout本票按固定协议/配置/时序讲解,未在本地搭完整外部生态,也不把它们删除成X。企业目录、SAML、证书和响应式实现归U31;凭据安全交付与生产密钥治理归U65。
条件变化:验证成本、失效窗口与恢复
以下是教学算例而非压测:订单API 2000请求/秒,AS introspection平均20毫秒、故障时200毫秒,JWT access TTL300秒,要求权限撤销10秒内生效。每请求远程校验时,Little定律给出平均在途约2000×0.020=40个;慢到0.200秒则约400个,还未加重试与尾延迟。缓存每主体/令牌结果5秒可减远程量,但撤销可见性至少受该窗口与传播影响;不能继续承诺即时。
| 方案 | 满足什么条件 | 成本、失效窗口与恢复责任 |
|---|---|---|
| 本地JWT + 短TTL | AS短故障不影响缓存内已知key;低验证网络成本 | 撤销若只靠exp可到300秒,不满足10秒。选更短TTL会增加刷新负载;若加版本/denylist还需分布式状态、容量和故障关闭策略 |
| 每请求introspection / 小缓存 | AS状态查询便于集中撤销;无缓存时下一请求可见AS已提交状态 | 增加网络与AS可用性依赖;5秒cache还需给传播/排队留预算。40→400在途需限流/连接上限/超时预算,恢复不能全量无界重试 |
混合方案可对高风险写操作查询当前权限/对象归属,对普通读使用短JWT;必须说明哪些操作仍存在旧权限窗口。若每日活跃20万token,denylist每项假设100字节,20万×100约20MB原始负载,不含索引/复制/allocator;DPoP若假设2000proof/s、保留60秒,需约12万去重项,两节点各自本地存不能保证同jti只用一次。这些数字只是给成本与不变量的输入,必须用实际基数/峰值/内存实测替换。
不变量:错issuer/aud/签名/时间不能获得身份;撤销承诺以明确窗口与AS提交点度量;scope不能越过owner/tenant;取钥/查询失败不能扩大权限。安全团队负责issuer/key/撤销策略,业务团队负责服务端资源关系,平台负责缓存/超时/容量与轮换监控。出现撤销SLO、缓存更新失败、key数/issuer数激增时,应重审策略而不是延长所有TTL。验证产物包括故障输入、不同节点观测时点、撤销前后状态、恢复后的正常控制,不能只比较成功率。
按失败层验证与恢复
最终公开证据:154次实际HTTP、七组PASS、入口退出0、三JVM定向回收、8源文件hash一致。角色/PKCE、JWT claim反例、reference、request/method/object/tenant、撤销/refresh、真实OIDC Client与RestClient、JWKS轮换分别成组。
排查先确定失败层:回调state/repository或nonce失败看Client保存请求与ID token验证;token400看grant/client/PKCE,不重试同code;JWT401检查允许算法、kid/JWKS、签名、iss、aud、时间与必需claim;合法身份403再看scope/方法/owner/tenant;503看AS/JWKS可用性和显式failure handler。日志只记录结果类、受控issuer/registration、匿名化关联ID及必要kid,不输出完整token、code、verifier、cookie或secret。
每个坏输入后都要恢复正常控制。端点503只证明本次故障响应,没有模拟所有TCP超时;缓存命中200不能单独证明网络已恢复,所以本实验另直连JWKS。固定源码解释默认与分支,真实运行解释本次显式配置,两者不能互换。题目自评记录不等于学习者实际掌握。
官方来源与版本边界
规范:OAuth2 RFC6749、PKCE RFC7636、安全最佳实践RFC9700、JWT BCP RFC8725、introspection RFC7662、revocation RFC7009、DPoP RFC9449、OIDC Core1.0。规范、Spring固定实现和本地观测分别引用。
官方实现固定Security7.0.8 commit 0102bb2e879e00632f30864785f0f46127877a4d:Client、Login、Resource Server、Authorization Server、HTTP Service集成。扩展未运行状态就近列明,核心实验不代表整个官方目录全部实测。
闭卷验收
本页 17 道闭卷题收进了下方自测面板:先回忆,再展开参考答案与评分点,最后给三档自评。