ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Spring Security+OAuth2+JWT实现轻量级单点登录

Spring Security+OAuth2+JWT实现轻量级单点登录 简介本资源是一套基于Spring Security OAuth2 JWT实现的单点登录SSO完整Demo面向Java后端开发者及Spring生态进阶学习者解决分布式系统中用户统一认证与授权的核心问题。项目采用标准授权码模式支持自定义登录页与授权确认页并提供内存存储与MySQL/Redis双模式Token管理方案附带完整建表SQL与配置说明服务端与客户端可独立运行联调。压缩包共302个文件以219个XML配置文件Spring框架配置、20个Java核心类含Security配置、OAuth2资源服务器与客户端实现、6个HTML页面自定义视图及4个YML配置文件为主整体大小26.67MB结构清晰、模块解耦。已有5115人学习下载读者可直接获取可运行工程、理解OAuth2授权流程落地细节、掌握JWT令牌签发与校验实践以及对比不同Token存储策略的适用场景与代码差异。1. 用 Spring Security OAuth2 JWT 搭建单点登录不是配几个注解就完事——它解决的是多系统间用户身份的可信传递问题你手上有三个内部系统HR 管理后台、报销审批平台、知识库 Wiki。每个系统都用 Spring Boot 写的各自维护一套用户表和登录页。运维抱怨每次新员工入职要手动在三套系统里各建一次账号安全团队发现某员工离职后HR 系统删了账号但 Wiki 还能凭旧 token 访问敏感文档前端同事改 SPA 路由时总被跳转到不同系统的登录页体验割裂。这不是“登录功能没写好”而是缺乏统一的身份认证契约。Spring Security OAuth2 JWT 组合正是为这类场景设计的轻量级 SSO 方案它不依赖 LDAP 或商业 IAM 产品用标准协议把认证中心Authorization Server和业务系统Resource Server解耦JWT 作为自包含的凭证载体在服务间免查库传递身份。本文不讲 OAuth2 四种授权模式的理论辨析只聚焦「如何用最少配置跑通一个可验证、可调试、可上线的 SSO Demo」——从 Authorization Server 的 token 签发逻辑到 Resource Server 的 JWT 解析与权限映射再到前端重定向链路的闭环验证。适合已会写 Controller 的 Java 开发者5 分钟内可复现核心流程。2. 搭建认证中心用 Spring Authorization Server 1.2 实现 OAuth2 授权码模式最小可行服务Spring Security 5.7 后官方推荐使用独立的 Spring Authorization Server 项目替代旧版spring-security-oauth2它基于 RFC 6749 和 RFC 7519 标准实现对 JWT 支持更规范。本节构建一个仅含/oauth2/authorize和/oauth2/token端点的轻量认证中心不嵌入用户管理 UI专注协议层能力。2.1 引入关键依赖与基础配置在pom.xml中声明 Spring Authorization Server 1.2.3适配 Spring Boot 3.2及 JWT 支持dependency groupIdorg.springframework.security/groupId artifactIdspring-security-oauth2-authorization-server/artifactId version1.2.3/version /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-validation/artifactId /dependency提示不要引入spring-security-oauth2-autoconfigure它已被废弃且与新授权服务器冲突。JWT 签名密钥必须用JWK Set格式提供而非简单字符串。2.2 配置内存用户与客户端注册创建InMemoryRegisteredClientRepository定义一个测试客户端如web-client和两个测试用户admin/userBean public RegisteredClientRepository registeredClientRepository() { RegisteredClient webClient RegisteredClient.withId(UUID.randomUUID().toString()) .clientId(web-client) .clientSecret({noop}secret123) // 生产环境务必用 BCrypt .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC) .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) .authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN) .redirectUri(http://localhost:8081/login/oauth2/code/web-client) // 前端回调地址 .scope(read) .scope(write) .build(); return new InMemoryRegisteredClientRepository(webClient); } Bean public UserDetailsService userDetailsService() { UserDetails admin User.withUsername(admin) .password({noop}admin123) // 密码编码器需匹配 .authorities(ROLE_ADMIN, SCOPE_read, SCOPE_write) .build(); UserDetails user User.withUsername(user) .password({noop}user123) .authorities(ROLE_USER, SCOPE_read) .build(); return new InMemoryUserDetailsManager(admin, user); }2.3 构建 JWT 签名密钥与 Token 自定义逻辑OAuth2 规范要求 Access Token 必须是 JWT且需数字签名。使用JWKSource提供 RSA 密钥对并在JwtEncoder中注入Bean public JWKSourceSecurityContext jwkSource() { KeyPair keyPair generateRsaKey(); // 生成 2048 位 RSA 密钥对 RSAPublicKey publicKey (RSAPublicKey) keyPair.getPublic(); RSAPrivateKey privateKey (RSAPrivateKey) keyPair.getPrivate(); JWKSet jwkSet new JWKSet(new RSAKey.Builder(publicKey) .privateKey(privateKey) .keyID(rsa-key-1) .build()); return (jwkSelector, securityContext) - jwkSet.getKeys().stream() .filter(jwkSelector::test) .map(jwk - ((RSAKey) jwk).toKeyPair()) .collect(Collectors.toList()); } Bean public JwtEncoder jwtEncoder(JWKSourceSecurityContext jwkSource) { return new NimbusJwtEncoder(jwkSource); } // 自定义 JWT Claims添加用户角色、部门等业务字段 Bean public OAuth2TokenCustomizerJwtEncodingContext jwtTokenCustomizer() { return context - { if (OAuth2TokenType.ACCESS_TOKEN.equals(context.getTokenType())) { context.getClaims().claim(roles, context.getPrincipal().getAuthorities().stream() .map(GrantedAuthority::getAuthority).collect(Collectors.toList())); context.getClaims().claim(dept, IT); // 示例业务字段 } }; }2.3.1 关键参数说明参数作用生产建议clientSecret客户端密钥用于 Basic Auth 认证使用 BCrypt 编码避免明文redirectUri授权码模式下用户授权后跳转地址必须与前端实际部署域名一致不可设为*JWKSource提供 JWT 签名公私钥生产环境应从密钥管理服务如 HashiCorp Vault动态加载jwtTokenCustomizer在 JWT Payload 中注入自定义字段避免放入敏感信息如手机号仅放权限标识2.4 启用 Authorization Server 并暴露端点通过EnableAuthorizationServer已废弃改用AuthorizationServerConfigurationConfiguration EnableWebSecurity public class AuthorizationServerConfig { Bean public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http) throws Exception { OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http); http.getConfigurer(OAuth2AuthorizationServerConfigurer.class) .oidc(Customizer.withDefaults()); // 启用 OIDC 支持 http.exceptionHandling((exceptions) - exceptions .authenticationEntryPoint(new LoginUrlAuthenticationEntryPoint(/login))); return http.build(); } Bean public ProviderSettings providerSettings() { return ProviderSettings.builder() .issuer(http://localhost:9000) // 必须是 HTTPS 生产环境 .build(); } }启动应用后访问http://localhost:9000/oauth2/authorize?response_typecodeclient_idweb-clientscopereadredirect_urihttp://localhost:8081/login/oauth2/code/web-client即可触发授权页面。输入admin/admin123后浏览器将重定向至http://localhost:8081/login/oauth2/code/web-client?codexxx完成授权码获取。3. 构建资源服务器解析 JWT 并校验签名、过期与权限范围资源服务器如 HR 系统不参与用户认证只负责验证传入的 JWT 是否有效并提取其中的roles和scope映射为 Spring Security 的GrantedAuthority。本节以 Spring Boot 3.2 为例展示零数据库查询的 JWT 校验链路。3.1 添加依赖与启用资源服务器模式dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-oauth2-resource-server/artifactId /dependency3.2 配置 JWT 解码器与权限映射规则在application.yml中指定认证中心的 JWK Set URL即http://localhost:9000/oauth2/jwksspring: security: oauth2: resourceserver: jwt: issuer-uri: http://localhost:9000 jwk-set-uri: http://localhost:9000/oauth2/jwksJava 配置中定义JwtAuthenticationConverter将 JWT 中的roles数组转为ROLE_前缀的权限Bean public JwtAuthenticationConverter jwtAuthenticationConverter() { JwtGrantedAuthoritiesConverter grantedAuthoritiesConverter new JwtGrantedAuthoritiesConverter(); grantedAuthoritiesConverter.setAuthoritiesClaimName(roles); // 对应 jwtTokenCustomizer 中的 claim key grantedAuthoritiesConverter.setAuthorityPrefix(ROLE_); JwtAuthenticationConverter jwtAuthenticationConverter new JwtAuthenticationConverter(); jwtAuthenticationConverter.setJwtGrantedAuthoritiesConverter(grantedAuthoritiesConverter); return jwtAuthenticationConverter; } Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http .authorizeHttpRequests(authz - authz .requestMatchers(/api/public/**).permitAll() .requestMatchers(/api/admin/**).hasRole(ADMIN) .requestMatchers(/api/user/**).hasAnyRole(ADMIN, USER) .anyRequest().authenticated() ) .oauth2ResourceServer(oauth2 - oauth2 .jwt(jwt - jwt .jwtAuthenticationConverter(jwtAuthenticationConverter()) ) ); return http.build(); }3.3 验证 JWT 解析效果用 curl 直接调用资源接口先用 Postman 或 curl 获取 Access Token# 步骤1用授权码换取 Access Token curl -X POST http://localhost:9000/oauth2/token \ -H Content-Type: application/x-www-form-urlencoded \ -u web-client:secret123 \ -d grant_typeauthorization_code \ -d codeYOUR_AUTHORIZATION_CODE \ -d redirect_urihttp://localhost:8081/login/oauth2/code/web-client响应体中access_token字段即为 JWT。将其放入请求头调用资源接口curl -X GET http://localhost:8080/api/user/profile \ -H Authorization: Bearer eyJraWQiOiJyc2EtaGV5LTEiLCJhbGciOiJSUzI1NiJ9...3.3.1 JWT 校验失败的典型日志与排查路径日志关键词常见原因快速验证命令Invalid signature公钥不匹配或密钥被篡改curl http://localhost:9000/oauth2/jwks查看 JWK 是否与服务端一致Expired JWTToken 过期时间exp已到echo JWT_PAYLOADInvalid audienceJWT 中aud字段不匹配资源服务器配置echo JWT_HEADER_PAYLOADMissing scope请求路径需要write权限但 Token 只含readecho JWT_PAYLOAD注意issuer-uri和jwk-set-uri必须指向同一域名否则JwtDecoder初始化失败。若认证中心启用了 HTTPS资源服务器也必须用 HTTPS 访问其 JWK 端点。3.4 处理 Refresh Token 续期逻辑OAuth2 规范要求 Access Token 短期有效如 30 分钟Refresh Token 长期有效如 7 天。资源服务器本身不处理 Refresh但需确保前端能正确使用# 用 Refresh Token 换新 Access Token curl -X POST http://localhost:9000/oauth2/token \ -H Content-Type: application/x-www-form-urlencoded \ -u web-client:secret123 \ -d grant_typerefresh_token \ -d refresh_tokenYOUR_REFRESH_TOKEN提示Refresh Token 必须存储在 HttpOnly Cookie 中禁止前端 JavaScript 访问防止 XSS 泄露。Spring Authorization Server 默认支持此机制无需额外配置。4. 前端集成与 SSO 闭环用 Spring Security OAuth2 Login 实现无感跳转单点登录的用户体验核心在于「一次登录全站通行」。本节以 Spring Boot 3.2 的spring-boot-starter-oauth2-client为基础构建前端网关如http://localhost:8081自动完成 OAuth2 登录流程并在多个子系统间共享登录态。4.1 配置 OAuth2 Client 属性在网关的application.yml中声明认证中心地址与客户端凭证spring: security: oauth2: client: registration: web-client: client-id: web-client client-secret: secret123 scope: read,write provider: web-client: authorization-uri: http://localhost:9000/oauth2/authorize token-uri: http://localhost:9000/oauth2/token user-info-uri: http://localhost:9000/oauth2/userinfo user-name-attribute: name4.2 启用 OAuth2 Login 并定制登录成功逻辑Configuration EnableWebSecurity public class WebSecurityConfig { Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http .authorizeHttpRequests(authz - authz .requestMatchers(/login, /error, /webjars/**).permitAll() .requestMatchers(/api/**).authenticated() .anyRequest().authenticated() ) .oauth2Login(oauth2 - oauth2 .redirectionEndpoint(redirect - redirect.baseUri(/login/oauth2/code/*)) .userInfoEndpoint(userInfo - userInfo.userService(this::customUserService)) .successHandler((request, response, authentication) - { // 登录成功后将 JWT 存入 HttpOnly Cookie String accessToken ((OAuth2AuthenticationToken) authentication) .getAccessToken().getTokenValue(); Cookie cookie new Cookie(X-Auth-Token, accessToken); cookie.setHttpOnly(true); cookie.setPath(/); cookie.setMaxAge(30 * 60); // 30 分钟 response.addCookie(cookie); response.sendRedirect(/dashboard); }) ); return http.build(); } private OAuth2UserServiceOAuth2UserRequest, OAuth2User customUserService() { DefaultOAuth2UserService delegate new DefaultOAuth2UserService(); return userRequest - { OAuth2User oAuth2User delegate.loadUser(userRequest); // 将 JWT 中的 roles 注入 OAuth2User MapString, Object attributes new HashMap(oAuth2User.getAttributes()); attributes.put(roles, oAuth2User.getAttribute(roles)); return new DefaultOAuth2User( oAuth2User.getAuthorities(), attributes, name ); }; } }4.3 多系统共享登录态反向代理 Cookie 路径统一假设 HR 系统部署在http://localhost:8080报销系统在http://localhost:8082网关http://localhost:8081作为统一入口。通过 Nginx 反向代理将/hr/**转发至 8080/expense/**转发至 8082并设置 Cookie 路径为根路径/location /hr/ { proxy_pass http://localhost:8080/; proxy_cookie_path / /; # 关键使 Cookie 对所有子路径生效 } location /expense/ { proxy_pass http://localhost:8082/; proxy_cookie_path / /; }前端发起 API 请求时自动携带X-Auth-TokenCookie资源服务器通过Bearer头或直接读取 Cookie 解析 JWT无需前端手动管理 Token。4.3.1 前端 AJAX 请求的 Token 透传方案若不使用反向代理前端需显式携带 Token// Vue 3 Composition API 示例 const api axios.create({ baseURL: http://localhost:8080, headers: { Authorization: Bearer ${document.cookie.split(; ).find(row row.startsWith(X-Auth-Token))?.split()[1] || } } });提示X-Auth-TokenCookie 的SameSiteLax属性可防止 CSRF但需确保所有子系统同域如*.company.com跨域场景需配合 CORS 配置Access-Control-Allow-Credentials: true。5. 生产级加固与排错技巧JWT 续签、黑名单与性能压测关键点SSO Demo 跑通只是起点生产环境需应对 Token 续期、恶意刷新、高并发解析等真实压力。本节给出可直接落地的加固策略不讲理论只列命令与配置。5.1 实现 JWT Token 续签Refresh Token 自动轮换Spring Authorization Server 默认不启用 Refresh Token 轮换即每次刷新生成新 Token作废旧 Token需手动开启Bean public OAuth2TokenCustomizerOAuth2RefreshTokenGenerationContext refreshTokenCustomizer() { return context - { // 强制每次刷新生成新 Refresh Token context.getAdditionalParameters().put(reuse_refresh_token, false); }; }同时在资源服务器中捕获InvalidBearerTokenException并重定向至登录页Component public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint { Override public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException { if (authException instanceof InvalidBearerTokenException) { response.sendRedirect(/login?errortoken_expired); } else { response.sendError(HttpServletResponse.SC_UNAUTHORIZED); } } }5.2 构建 JWT 黑名单机制用 Redis 存储已注销 TokenJWT 本质是无状态的但用户主动登出需立即失效 Token。方案将 Access Token 的jtiJWT ID存入 Redis设置过期时间等于 Token 有效期Service public class JwtBlacklistService { private final RedisTemplateString, String redisTemplate; public JwtBlacklistService(RedisTemplateString, String redisTemplate) { this.redisTemplate redisTemplate; } public void addToBlacklist(String jti, long expireSeconds) { redisTemplate.opsForValue().set(blacklist: jti, invalid, Duration.ofSeconds(expireSeconds)); } public boolean isBlacklisted(String jti) { return redisTemplate.hasKey(blacklist: jti); } }在资源服务器的JwtAuthenticationConverter前插入校验逻辑Bean public JwtDecoder jwtDecoder(JWKSourceSecurityContext jwkSource) { NimbusJwtDecoder jwtDecoder (NimbusJwtDecoder) JwtDecoders.fromIssuerLocation(http://localhost:9000); jwtDecoder.setJwtValidator(token - { JwtValidators.createDefault().validate(token); String jti token.getJti(); if (jti ! null jwtBlacklistService.isBlacklisted(jti)) { throw new JwtValidationException(JWT is blacklisted); } }); return jwtDecoder; }5.3 压测 JWT 解析性能用 JMH 测试 10 万次解析耗时JWT 解析是 CPU 密集型操作需确认是否成为瓶颈。编写 JMH 基准测试Fork(1) Warmup(iterations 3) Measurement(iterations 5) public class JwtDecodeBenchmark { private static final String VALID_JWT eyJraWQiOiJyc2EtaGV5LTEiLCJhbGciOiJSUzI1NiJ9...; // 有效 JWT 示例 private JwtDecoder jwtDecoder; Setup public void setup() { jwtDecoder JwtDecoders.fromIssuerLocation(http://localhost:9000); } Benchmark public Jwt decode() { return jwtDecoder.decode(VALID_JWT); } }运行结果示例Intel i7-10875HBenchmark Mode Cnt Score Error Units JwtDecodeBenchmark.decode avgt 5 0.042 ± 0.001 ms/op提示若单次解析超 0.1ms需检查 JWK 加载是否频繁网络请求应缓存JWKSet、RSA 密钥长度是否过大2048 位足够4096 位性能下降 4 倍。5.4 查看实时 JWT 内容与调试技巧开发时快速解码 JWT避免反复调用接口# Linux/macOS用 openssl 和 jq 一行解码 echo YOUR_JWT | awk -F. {print $1.$2} | base64 -d | jq . # Windows PowerShell用内置 Base64 解码 $jwt YOUR_JWT; $parts $jwt -split \.; $payload [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($parts[1].PadRight(($parts[1].Length 3) % 4, ))); ConvertFrom-Json $payload当遇到Invalid signature错误优先比对认证中心http://localhost:9000/oauth2/jwks返回的kid与 JWT Header 中的kid是否一致不一致则密钥未同步。本文还有配套的精品资源点击获取
返回列表