ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

Spring Security OAuth2 单点登录与 JWT 资源服务器实战

Spring Security OAuth2 单点登录与 JWT 资源服务器实战 简介这是一份面向Java后端开发者的Spring Security OAuth2单点登录SSO实战教程PDF适合已掌握Spring Boot基础、希望理解OAuth2授权流程与SSO落地方式的中级开发者可用于搭建统一认证体系或改造多系统登录场景。资源包内共1个PDF文件约75KB篇幅精炼以代码与配置说明为主线。内容围绕授权服务器与两个客户端应用的三方结构展开依次讲解pom.xml依赖引入、EnableOAuth2Sso与WebSecurityConfigurerAdapter的安全配置、application.yml中clientId、clientSecret、accessTokenUri、userAuthorizationUri与userInfoUri等客户端参数以及EnableAuthorizationServer下的授权服务器与资源服务器合并部署方式并采用授权码授权类型驱动认证委派附带index.html与securedPage.html前端页面示例。已有3578人学习便于读者快速理清SSO请求重定向、令牌获取与用户信息读取的完整链路对照代码完成本地多应用联调与排错。1. 从一次跨系统登录说起Spring Security OAuth2 单点登录解决什么问题一个典型场景公司有报表、工单、监控三个后台各自维护账号。员工登录每个系统都要输一次密码运维还要处理离职账号残留。把登录收敛到统一的授权服务器后用户只在授权服务器认证一次各应用通过 OAuth2 授权码模式拿到身份和令牌这就是单点登录要解决的核心问题。Spring Security OAuth2 把客户端、资源服务器和授权服务器的职责拆开适合已有 Spring Boot 3、Spring Security 6 技术栈的团队。它不要求所有应用同域也不要求共享 Cookie跨域、多终端场景都能落地。代价是令牌校验、登出和会话同步需要额外设计后面按可复现的步骤拆开讲。2. 授权码模式与 Spring Security OAuth2 的角色划分先把协议摆正。OAuth2 本身不是登录协议但授权码模式加上 OIDC 的 id_token 后能完成单点登录。理解四次握手后面配什么都不容易乱。2.1 授权码模式在单点登录里的四次握手第一次客户端把浏览器重定向到授权服务器的/oauth2/authorize带上 client_id、redirect_uri、scope、state。第二次用户在授权服务器登录并同意授权授权服务器把浏览器重定向回 redirect_uri并附带 code。第三次客户端后端拿 code 和 client_secret 调/oauth2/token换取 access_token、refresh_token、id_token。第四次客户端用 access_token 调资源服务器的 API资源服务器校验令牌后返回数据。# 第一步在浏览器打开授权地址登录后会被重定向回客户端回调地址 http://127.0.0.1:9000/oauth2/authorize?response_typecodeclient_idsso-clientscopeopenid%20profileredirect_urihttp://127.0.0.1:8081/login/oauth2/code/sso-clientstateabc123逻辑说明response_typecode 指定授权码模式state 防止跨站请求伪造客户端回调时要原样比对scope 里带 openid 才会走 OIDC 并返回 id_token。参数说明如下。参数作用单点登录里的注意点response_type指定授权类型固定为 code不要用 implicitclient_id客户端标识授权服务器中 RegisteredClient 的 clientIdredirect_uri回调地址必须与注册值完全一致包括端口和路径scope申请权限openid 触发 OIDCprofile 取用户信息state防跨站请求伪造客户端生成并校验Spring Security 已自动处理2.2 Spring Security OAuth2 的客户端、授权服务器、资源服务器在 Spring Security 生态里单点登录至少涉及三个角色。客户端是业务应用依赖spring-boot-starter-oauth2-client授权服务器负责登录、发码、发令牌常见做法是用 Spring Authorization Server它属于 Spring Security 的 OAuth2 授权服务器实现资源服务器是受保护 API依赖spring-boot-starter-oauth2-resource-server。三者之间只靠标准端点和令牌通信不需要共享数据库。角色典型依赖要配什么常见错误客户端oauth2-clientclient-id、secret、issuer-uri、redirect-uriredirect-uri 与注册值不一致导致 invalid_redirect_uri授权服务器oauth2-authorization-serverRegisteredClient、JWKSource、issuerissuer 配错导致资源服务器校验失败资源服务器oauth2-resource-serverissuer-uri 或 jwk-set-uri只配 jwk-set-uri 不校验 issuer 会留下风险选择这种拆法的理由很直接客户端不接触用户密码资源服务器不处理登录页授权服务器只做认证和令牌签发。每层职责单一排错时也容易定位是回调地址、令牌校验还是权限映射的问题。2.3 选型Spring Authorization Server 还是 CAS 单点登录搭建与 LDAP 统一用户认证如果团队已经在 Spring Boot 3 和 Spring Security 6 上做开发直接引入 Spring Authorization Server 最顺手客户端和资源服务器都是同一套 Security 配置风格。CAS 单点登录搭建更成熟协议独立适合历史系统多、语言杂、已有 CAS 运维经验的场景LDAP 统一用户认证和单点登录则常用于企业内网把用户目录放在 LDAP授权服务器通过 LDAP 绑定校验密码再做 OAuth2 发令牌。方案适合场景接入成本令牌形态与 Spring Security OAuth2 的关系Spring Authorization Server新系统、Spring 技术栈统一低JWT / 不透明令牌原生集成客户端与资源服务器复用CAS异构系统、已有 CAS 服务中CAS ticket可做 OAuth2 对接但多一层协议转换LDAP OAuth2内网统一用户目录中高JWT只解决认证源授权服务器仍需实现注意不要把 LDAP 当成单点登录协议它负责账号校验和用户属性读取令牌签发和会话管理还是交给授权服务器。3. 用 Spring Boot 3 Security 6 搭建 OAuth2 授权服务器3.1 最小依赖与授权服务器自动配置先建一个授权服务器模块。pom.xml 中引入dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-oauth2-authorization-server/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-jdbc/artifactId /dependency逻辑说明authorization-server starter 会带入 Spring Security 和 Nimbus JOSE JWTjdbc 用于把注册客户端和授权记录落库避免重启后客户端丢失。application.yml 配端口和数据源server: port: 9000 spring: datasource: url: jdbc:mysql://127.0.0.1:3306/sso?useSSLfalseserverTimezoneAsia/Shanghai username: sso password: sso_passissuer 不写在 yml而是在代码里用AuthorizationServerSettings指定常见做法是http://127.0.0.1:9000。注意 issuer 必须与客户端和资源服务器看到的一致否则 JWT 的 iss 校验不通过。3.2 注册 OAuth2 客户端与令牌设置授权服务器核心是 RegisteredClient。代码Bean public RegisteredClientRepository registeredClientRepository(JdbcTemplate jdbcTemplate) { RegisteredClient client RegisteredClient.withId(UUID.randomUUID().toString()) .clientId(sso-client) .clientSecret({noop}secret) // 生产环境应使用 BCrypt .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC) .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) .authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN) .redirectUri(http://127.0.0.1:8081/login/oauth2/code/sso-client) .postLogoutRedirectUri(http://127.0.0.1:8081/) .scope(OidcScopes.OPENID) .scope(OidcScopes.PROFILE) .clientSettings(ClientSettings.builder().requireAuthorizationConsent(true).build()) .tokenSettings(TokenSettings.builder() .accessTokenFormat(OAuth2TokenFormat.SELF_CONTAINED) // JWT .accessTokenTimeToLive(Duration.ofMinutes(30)) .refreshTokenTimeToLive(Duration.ofHours(8)) .reuseRefreshTokens(false) .authorizationCodeTimeToLive(Duration.ofMinutes(5)) .build()) .build(); JdbcRegisteredClientRepository repository new JdbcRegisteredClientRepository(jdbcTemplate); if (repository.findByClientId(sso-client) null) { repository.save(client); } return repository; }逻辑说明RegisteredClient 定义了谁可以接入、用什么方式换令牌、令牌活多久。参数说明如下。配置项示例值作用调整建议clientIdsso-client客户端标识每个应用独立不要复用clientSecret{noop}secret客户端密码生产用 BCrypt走密钥管理redirectUrihttp://127.0.0.1:8081/login/oauth2/code/sso-client回调地址必须精确匹配不能带通配符accessTokenTimeToLive30m访问令牌有效期越短越安全但要配合刷新令牌refreshTokenTimeToLive8h刷新令牌有效期覆盖一个工作日即可reuseRefreshTokensfalse是否复用刷新令牌设为 false 可轮换刷新令牌authorizationCodeTimeToLive5m授权码有效期默认较短不要放大注意requireAuthorizationConsent(true)会在第一次登录时弹出授权确认页内部系统可以设为 false 减少点击面向第三方应用时保持 true。3.3 自定义登录页与 JDBC 存储授权信息授权服务器需要两个 SecurityFilterChain一个处理 OAuth2 协议端点一个处理表单登录。代码Configuration EnableWebSecurity public class AuthorizationServerConfig { Bean Order(1) public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http) throws Exception { OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http); http.getConfigurer(OAuth2AuthorizationServerConfigurer.class) .oidc(Customizer.withDefaults()); // 开启 OIDC客户端才能拿到 id_token http.exceptionHandling(e - e.defaultAuthenticationEntryPointFor( new LoginUrlAuthenticationEntryPoint(/login), new MediaTypeRequestMatcher(MediaType.TEXT_HTML))); return http.build(); } Bean Order(2) public SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception { http.authorizeHttpRequests(auth - auth.anyRequest().authenticated()) .formLogin(Customizer.withDefaults()); // 自定义登录页可替换这里 return http.build(); } }逻辑说明Order(1)的过滤器链只匹配授权服务器端点未登录时跳/loginOrder(2)负责普通请求和表单登录。JDBC 存储授权记录用JdbcOAuth2AuthorizationService建表 SQL 使用 Spring Authorization Server 提供的 schema 脚本。常见坑是只存了 RegisteredClient没存 OAuth2Authorization导致重启后刷新令牌失效。生产环境可以把授权记录放 Redis 或数据库按团队运维习惯选。4. 接入单点登录客户端Spring Security OAuth2 Client 配置与登录流程4.1 客户端依赖与 application.yml 关键参数业务应用引入dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-oauth2-client/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependencyapplication.ymlserver: port: 8081 spring: security: oauth2: client: registration: sso-client: client-id: sso-client client-secret: secret authorization-grant-type: authorization_code redirect-uri: {baseUrl}/login/oauth2/code/{registrationId} scope: openid,profile provider: sso-client: issuer-uri: http://127.0.0.1:9000逻辑说明registration 下的 key 是客户端名称会出现在回调路径/login/oauth2/code/sso-client里provider 的 issuer-uri 让 Spring Security 自动拉取.well-known/openid-configuration从而发现授权端点、令牌端点和 JWK 地址。参数说明如下。参数作用配错后的现象client-id客户端标识授权服务器返回 invalid_clientclient-secret客户端密码换令牌时 401scope申请权限缺少 openid 时没有 id_tokenredirect-uri回调模板与注册值不一致时 invalid_redirect_uriissuer-uri发现配置入口拿不到端点或 iss 校验失败注意{baseUrl}会按当前请求的协议、域名、端口展开网关转发场景要确保 X-Forwarded-* 头正确传递否则回调地址会变成内网地址。4.2 登录跳转、回调与 SecurityFilterChain 放行规则客户端安全配置Configuration EnableWebSecurity public class ClientSecurityConfig { Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http.authorizeHttpRequests(auth - auth .requestMatchers(/, /public/**, /error).permitAll() .anyRequest().authenticated()) .oauth2Login(oauth2 - oauth2 .loginPage(/oauth2/authorization/sso-client) .defaultSuccessUrl(/home, true)); return http.build(); } }逻辑说明未登录用户访问受保护资源时会被重定向到/oauth2/authorization/sso-client再跳授权服务器用户登录后授权服务器回调/login/oauth2/code/sso-clientSpring Security 自动用 code 换令牌并把用户信息放进 SecurityContext。defaultSuccessUrl(/home, true)表示登录后总是回 /home适合单点登录入口统一的应用。4.3 调用下游资源服务器把访问令牌带过去客户端拿到 access_token 后调资源服务器要手动带 Authorization 头。代码RestController public class OrderController { private final RestClient restClient; private final OAuth2AuthorizedClientService authorizedClientService; public OrderController(RestClient.Builder builder, OAuth2AuthorizedClientService authorizedClientService) { this.restClient builder.baseUrl(http://127.0.0.1:9001).build(); this.authorizedClientService authorizedClientService; } GetMapping(/orders) public String orders(AuthenticationPrincipal OAuth2User user) { OAuth2AuthorizedClient client authorizedClientService.loadAuthorizedClient( sso-client, user.getName()); // user.getName() 通常对应 id_token 的 sub String token client.getAccessToken().getTokenValue(); return restClient.get() .uri(/api/orders) .header(HttpHeaders.AUTHORIZATION, Bearer token) .retrieve() .body(String.class); } }逻辑说明OAuth2AuthorizedClientService 按客户端注册名和用户主体名取令牌access_token 放在 Bearer 头里传给资源服务器。参数说明user.getName()在 OIDC 下默认是 sub如果自定义了OidcUserService改了 name attribute要同步调整。令牌过期时可以注入OAuth2AuthorizedClientManager自动刷新而不是直接抛 401。5. 资源服务器校验 JWT 与单点登出、会话共享的落地细节5.1 资源服务器用 JWT 解码器校验令牌资源服务器只做两件事校验令牌、从令牌里取权限。依赖dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-oauth2-resource-server/artifactId /dependencyapplication.ymlserver: port: 9001 spring: security: oauth2: resourceserver: jwt: issuer-uri: http://127.0.0.1:9000配置类Configuration EnableWebSecurity public class ResourceServerConfig { Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http.authorizeHttpRequests(auth - auth .requestMatchers(/api/public/**).permitAll() .anyRequest().authenticated()) .oauth2ResourceServer(oauth2 - oauth2 .jwt(jwt - jwt.jwtAuthenticationConverter(jwtAuthenticationConverter()))); return http.build(); } private ConverterJwt, AbstractAuthenticationToken jwtAuthenticationConverter() { JwtAuthenticationConverter converter new JwtAuthenticationConverter(); converter.setJwtGrantedAuthoritiesConverter(jwt - { ListString scopes jwt.getClaimAsStringList(scope); return scopes null ? List.of() : scopes.stream() .map(s - new SimpleGrantedAuthority(SCOPE_ s)) .collect(Collectors.toList()); }); return converter; } }逻辑说明issuer-uri 会自动发现 JWK 地址并校验 issjwtAuthenticationConverter 把 scope 转成 SCOPE_ 前缀的权限方便在方法上用PreAuthorize(hasAuthority(SCOPE_profile))。JWT claim含义资源服务器用法iss签发者必须与 issuer-uri 一致sub用户唯一标识映射到业务用户表aud接收方多资源服务器时用于限定受众exp过期时间解码时自动校验scope授权范围转成 SCOPE_ 权限jti令牌唯一 ID登出黑名单或审计注意不要在资源服务器里只解 JWT 不验签必须走 JWK 公钥验签否则任何人都能伪造令牌。5.2 单点登出前端通道、后端通道与本地会话清理OAuth2 本身不定义登出OIDC 提供了 RP-Initiated Logout。客户端配置Bean public LogoutSuccessHandler oidcLogoutSuccessHandler(ClientRegistrationRepository repo) { OidcClientInitiatedLogoutSuccessHandler handler new OidcClientInitiatedLogoutSuccessHandler(repo); handler.setPostLogoutRedirectUri({baseUrl}); // 登出后回客户端首页 return handler; }在 SecurityFilterChain 中挂上http.logout(logout - logout.logoutSuccessHandler(oidcLogoutSuccessHandler(clientRegistrationRepository)));逻辑说明用户点登出后客户端先清本地会话再把浏览器重定向到授权服务器的/connect/logout授权服务器清自己的会话最后回 post_logout_redirect_uri。后端通道登出则由授权服务器向各客户端回调地址推送 logout_token适合无法控制浏览器的场景。常见坑是只清了客户端会话授权服务器会话还在用户再次访问又自动登录。5.3 多应用会话共享与 Redis 存储多个应用要共享登录状态不能只靠各应用内存会话。用 Spring Session Redisspring: session: store-type: redis redis: namespace: sso:session flush-mode: on_save data: redis: host: 127.0.0.1 port: 6379逻辑说明session 落到 Redis 后同一浏览器访问多个应用时只要 Cookie 中的 SESSION 指向同一份会话数据就能减少重复登录。注意 OAuth2 的 access_token 不要直接塞进共享 CookieCookie 里只放会话 ID令牌由后端按需取用。存储对象推荐位置原因用户会话Redis多应用共享重启不丢RegisteredClient数据库变更少需审计OAuth2Authorization数据库或 Redis刷新令牌、授权码需要持久化JWT 签名私钥密钥管理服务不能硬编码在代码库6. 排错与进阶令牌失效、跨域、生产环境参数怎么调6.1 用 curl 和日志定位 401/403 与 invalid_token先确认发现端点curl -i http://127.0.0.1:9000/.well-known/openid-configuration返回里能看到issuer、authorization_endpoint、token_endpoint、jwks_uri。如果 401继续看资源服务器日志里的WWW-Authenticate头curl -i -H Authorization: Bearer $TOKEN http://127.0.0.1:9001/api/orders常见输出现象可能原因处理401 invalid_token令牌过期、iss 不匹配、签名不对检查 exp、issuer-uri、JWK 地址403 insufficient_scopescope 不足在 RegisteredClient 和客户端配置补 scope302 循环跳转客户端回调地址与注册值不一致比对 redirect_uri 的端口和路径CORS 报错前端直连资源服务器在资源服务器配 CorsConfigurationSource6.2 时钟偏移、issuer 校验与 JWK 轮换的处理多台机器时间不一致时JWT 的 exp 校验会误判。资源服务器可以给解码器加时钟偏移Bean public JwtDecoder jwtDecoder() { NimbusJwtDecoder decoder NimbusJwtDecoder .withIssuerLocation(http://127.0.0.1:9000) .build(); decoder.setJwtValidator(new DelegatingOAuth2TokenValidator( JwtValidators.createDefaultWithIssuer(http://127.0.0.1:9000), new JwtTimestampValidator(Duration.ofSeconds(60)))); return decoder; }逻辑说明createDefaultWithIssuer校验 iss 和时间JwtTimestampValidator允许 60 秒偏移。JWK 轮换时授权服务器换签名密钥资源服务器通过 jwks_uri 定时刷新公钥如果缓存时间太长会出现旧令牌验签失败。生产环境把授权服务器时间同步做好比放宽偏移更可靠。6.3 生产参数表与压测观察点参数保守值说明accessTokenTimeToLive15m 到 30m越短泄露风险越小刷新频率越高refreshTokenTimeToLive8h 到 24h与业务在线时长匹配reuseRefreshTokensfalse刷新令牌轮换旧令牌立即失效authorizationCodeTimeToLive2m 到 5m授权码是一次性的session timeout30m 到 2h与前端空闲提示配合jwk cache5m 到 15m平衡密钥轮换和请求量压测时看三个指标JWT 解码耗时、Redis 会话命中率、授权码换令牌的失败率。如果解码耗时随 QPS 线性上升优先升级 CPU 或换更短的密钥如果 Redis 命中率低检查 session 是否被多个应用重复写入。把这三个指标压到正常区间再决定是否延长 access token 生命周期。本文还有配套的精品资源点击获取
返回列表