简介:本资源是一套基于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> <groupId>org.springframework.security</groupId> <artifactId>spring-security-oauth2-authorization-server</artifactId> <version>1.2.3</version> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-validation</artifactId> </dependency>提示:不要引入
spring-security-oauth2-autoconfigure,它已被废弃且与新授权服务器冲突。JWT 签名密钥必须用JWK Set格式提供,而非简单字符串。
2.2 配置内存用户与客户端注册
创建InMemoryRegisteredClientRepository,定义一个测试客户端(如web-client)和两个测试用户(admin/user):
@Bean 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 JWKSource<SecurityContext> 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(JWKSource<SecurityContext> jwkSource) { return new NimbusJwtEncoder(jwkSource); } // 自定义 JWT Claims:添加用户角色、部门等业务字段 @Bean public OAuth2TokenCustomizer<JwtEncodingContext> 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已废弃,改用AuthorizationServerConfiguration:
@Configuration @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_type=code&client_id=web-client&scope=read&redirect_uri=http://localhost:8081/login/oauth2/code/web-client即可触发授权页面。输入admin/admin123后,浏览器将重定向至http://localhost:8081/login/oauth2/code/web-client?code=xxx,完成授权码获取。
3. 构建资源服务器:解析 JWT 并校验签名、过期与权限范围
资源服务器(如 HR 系统)不参与用户认证,只负责验证传入的 JWT 是否有效,并提取其中的roles和scope映射为 Spring Security 的GrantedAuthority。本节以 Spring Boot 3.2 为例,展示零数据库查询的 JWT 校验链路。
3.1 添加依赖与启用资源服务器模式
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-oauth2-resource-server</artifactId> </dependency>3.2 配置 JWT 解码器与权限映射规则
在application.yml中指定认证中心的 JWK Set URL(即http://localhost:9000/oauth2/jwks):
spring: 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_type=authorization_code" \ -d "code=YOUR_AUTHORIZATION_CODE" \ -d "redirect_uri=http://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 JWT | Token 过期时间(exp)已到 | `echo "JWT_PAYLOAD" |
Invalid audience | JWT 中aud字段不匹配资源服务器配置 | `echo "JWT_HEADER_PAYLOAD" |
Missing scope | 请求路径需要write权限但 Token 只含read | `echo "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_type=refresh_token" \ -d "refresh_token=YOUR_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 OAuth2UserService<OAuth2UserRequest, OAuth2User> customUserService() { DefaultOAuth2UserService delegate = new DefaultOAuth2UserService(); return userRequest -> { OAuth2User oAuth2User = delegate.loadUser(userRequest); // 将 JWT 中的 roles 注入 OAuth2User Map<String, 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 的SameSite=Lax属性可防止 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 OAuth2TokenCustomizer<OAuth2RefreshTokenGenerationContext> 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?error=token_expired"); } else { response.sendError(HttpServletResponse.SC_UNAUTHORIZED); } } }5.2 构建 JWT 黑名单机制:用 Redis 存储已注销 Token
JWT 本质是无状态的,但用户主动登出需立即失效 Token。方案:将 Access Token 的jti(JWT ID)存入 Redis,设置过期时间等于 Token 有效期:
@Service public class JwtBlacklistService { private final RedisTemplate<String, String> redisTemplate; public JwtBlacklistService(RedisTemplate<String, 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(JWKSource<SecurityContext> 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-10875H):
Benchmark 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是否一致,不一致则密钥未同步。
本文还有配套的精品资源,点击获取