Spring Security OAuth2实战:构建基于JWT的微服务认证授权中心

发布时间:2026/7/18 1:48:09
Spring Security OAuth2实战:构建基于JWT的微服务认证授权中心 1. 项目概述与核心价值在微服务架构成为主流的今天一个独立、健壮且可扩展的认证授权中心早已不是“锦上添花”的选项而是保障系统安全的“基础设施”。前两篇我们分别完成了Spring Security的基础整合与OAuth2授权码模式的初步实现但那更像是在自家院子里搭了个简易门岗。今天这篇实战我们要把这个门岗升级成一个功能完备、流程清晰、能应对复杂业务场景的“认证服务中心”。简单来说我们要构建的是一个标准的OAuth2授权服务器。它不再仅仅是返回一个令牌而是要能清晰地定义谁可以发令牌客户端、令牌能访问哪些资源作用域、令牌的有效期多长并且为后续的资源服务器校验令牌提供坚实的依据。很多朋友在整合Spring Security OAuth2时常常卡在配置繁琐、概念混淆上感觉配了一堆却不知道为何这样配。这次我会带你从“为什么要这样设计”的角度出发一步步搭建并分享我在实际项目中趟过的坑比如如何优雅地处理客户端信息存储、如何自定义令牌增强信息、以及如何与JWT无缝结合。无论你是想为内部多个微服务提供统一登录还是需要为第三方应用开放API接口这套方案都能给你一个清晰、可落地的参考。2. 认证服务核心设计与思路拆解在动手写代码之前我们必须先想清楚这个认证服务要承担哪些职责以及Spring Security OAuth2的配置模型是如何工作的。这能避免我们陷入“复制粘贴配置却不知其所以然”的困境。2.1 OAuth2授权服务器核心组件一个标准的OAuth2授权服务器主要包含以下几个关键配置点它们共同决定了认证流程的行为客户端详情服务 (ClientDetailsService)定义哪些应用客户端可以来申请令牌。需要配置客户端的ID、密钥、授权类型、授权范围、重定向URI等。这相当于给来访的应用程序发“通行证”。令牌服务 (AuthorizationServerTokenServices)负责令牌的创建、存储、刷新和吊销。它是令牌生命周期的管理者。授权端点 (AuthorizationEndpoint)处理用户授权请求的入口例如/oauth/authorize。通常需要与登录页面结合。令牌端点 (TokenEndpoint)处理令牌申请和刷新请求的入口例如/oauth/token。端点安全配置 (AuthorizationServerSecurityConfigurer)配置上述端点自身的访问安全规则比如/oauth/token这个端点本身是否需要认证。Spring Security OAuth2通过EnableAuthorizationServer注解和一个继承自AuthorizationServerConfigurerAdapter的配置类让我们能够集中配置以上所有内容。2.2 技术选型与存储方案考量在之前的文章中我们可能将客户端信息硬编码在配置类里。这对于演示可以但对于生产环境是绝对不行的。生产环境中客户端信息、授权记录、令牌信息都需要持久化。客户端信息存储我们将采用数据库存储。Spring Security OAuth2提供了一套默认的Schema我们可以直接使用也可以自定义。为了清晰和可控我推荐在项目初期使用自定义表结构然后通过实现ClientDetailsService和ClientRegistrationService接口来对接。令牌存储方案这是性能和安全的关键抉择。内存存储 (InMemoryTokenStore)仅适用于单机演示重启即丢失无法集群。数据库存储 (JdbcTokenStore)将令牌存储在数据库便于管理、吊销和集群共享。但每次资源服务器校验令牌都需要查库对数据库压力较大。JWT令牌 (JwtTokenStore)这是我们本次实战的重点和推荐方案。令牌本身包含所有必要的用户信息和权限Claims资源服务器只需用公钥验证签名即可无需每次查询数据库或认证服务器极大提升了性能和解耦性。令牌一旦签发在有效期内无法直接吊销这是其缺点通常通过设置较短的过期时间来缓解。我的实操心得在微服务架构下JWT几乎是标配。它的无状态特性完美契合了RESTful API和分布式系统。对于吊销问题实践中我们通常采用“黑名单”机制将需要提前失效的令牌ID存入Redis并设置短于令牌有效期的TTL或者直接接受短令牌带来的频繁刷新。这比每次请求都进行数据库IO要划算得多。基于以上分析我们本次的架构思路是使用数据库存储客户端详情使用JWT作为令牌格式并自定义JWT中的内容以适应业务需求。3. 核心细节解析与实操要点明确了设计思路我们来深入每个核心环节的配置细节和注意事项。3.1 数据库表结构设计与初始化首先我们需要创建存储客户端信息的表。虽然OAuth2有官方推荐的表结构但为了更贴合业务我们常自定义。这里是一个简化但足够用的设计CREATE TABLE oauth_client_details ( client_id varchar(256) NOT NULL COMMENT 客户端ID, client_secret varchar(256) NOT NULL COMMENT 客户端密钥, resource_ids varchar(256) DEFAULT NULL COMMENT 资源ID集合逗号分隔, scope varchar(256) DEFAULT NULL COMMENT 授权范围逗号分隔, authorized_grant_types varchar(256) DEFAULT NULL COMMENT 授权类型逗号分隔, web_server_redirect_uri varchar(256) DEFAULT NULL COMMENT 重定向URI, authorities varchar(256) DEFAULT NULL COMMENT 客户端权限逗号分隔, access_token_validity int(11) DEFAULT NULL COMMENT 访问令牌有效期(秒), refresh_token_validity int(11) DEFAULT NULL COMMENT 刷新令牌有效期(秒), additional_information varchar(4096) DEFAULT NULL COMMENT 附加信息JSON格式, autoapprove varchar(256) DEFAULT NULL COMMENT 自动授权的Scope, PRIMARY KEY (client_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT客户端信息表;初始化一条测试数据INSERT INTO oauth_client_details ( client_id, client_secret, resource_ids, scope, authorized_grant_types, web_server_redirect_uri, authorities, access_token_validity, refresh_token_validity, autoapprove ) VALUES ( test-client, -- 客户端ID {bcrypt}$2a$10$NlBCwMVmprW7/OHHp3qkDuTgHqYpZQkFjR3NpJ7kXqXx5VcDdLz1O, -- 密钥这里是经过BCrypt加密的‘secret’ order-service,user-service, -- 能访问的资源服务 read,write, -- 授权范围 authorization_code,password,refresh_token,implicit, -- 支持的授权模式 http://localhost:8081/login/oauth2/code/test, -- 授权码模式回调地址 ROLE_CLIENT, -- 客户端角色 7200, -- access_token 2小时过期 2592000, -- refresh_token 30天过期 read -- 自动批准‘read’ scope无需用户手动点击授权 );注意client_secret字段存储的是加密后的密码。这里使用了Spring Security推荐的BCryptPasswordEncoder进行加密。在生产环境中绝对不要明文存储密钥。你可以写一个简单的单元测试或使用在线工具生成BCrypt密文。3.2 授权服务器核心配置类详解接下来是重头戏授权服务器的配置类。我们将分块解析。Configuration EnableAuthorizationServer // 1. 启用授权服务器 public class AuthorizationServerConfig extends AuthorizationServerConfigurerAdapter { Autowired private AuthenticationManager authenticationManager; // 用于密码模式 Autowired private DataSource dataSource; // 数据源用于连接数据库 Autowired private UserDetailsService userDetailsService; // 用于获取用户详情刷新令牌时可能用到 /** * 配置客户端详情服务 */ Override public void configure(ClientDetailsServiceConfigurer clients) throws Exception { // 使用JdbcClientDetailsService从数据库读取客户端配置 clients.withClientDetails(new JdbcClientDetailsService(dataSource)); } /** * 配置授权、令牌端点及令牌服务 */ Override public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception { endpoints .authenticationManager(authenticationManager) // 密码模式必需 .userDetailsService(userDetailsService) // 刷新令牌时用于重新加载用户信息 .tokenStore(tokenStore()) // 指定令牌存储策略为JWT .accessTokenConverter(jwtAccessTokenConverter()) // 指定JWT转换器 .reuseRefreshTokens(false) // 刷新令牌后是否重用旧令牌false则每次刷新都产生新令牌 .pathMapping(/oauth/confirm_access, /custom/confirm); // 可以自定义端点路径 } /** * 配置令牌端点的安全约束 */ Override public void configure(AuthorizationServerSecurityConfigurer security) throws Exception { security .tokenKeyAccess(permitAll()) // /oauth/token_key 公开用于获取JWT签名公钥 .checkTokenAccess(isAuthenticated()) // /oauth/check_token 需认证资源服务器校验令牌 .allowFormAuthenticationForClients(); // 允许客户端使用表单认证即client_id, client_secret放在请求体中而非Header } /** * 定义令牌存储为JWT */ Bean public TokenStore tokenStore() { return new JwtTokenStore(jwtAccessTokenConverter()); } /** * 定义JWT令牌的转换器签名、增强 */ Bean public JwtAccessTokenConverter jwtAccessTokenConverter() { JwtAccessTokenConverter converter new JwtAccessTokenConverter(); // 设置JWT签名密钥对称密钥生产环境建议使用非对称密钥对 converter.setSigningKey(my-signing-key-which-is-very-long-and-secure); // 如果你想自定义JWT中的claims可以设置一个TokenEnhancer链 // converter.setAccessTokenConverter(customAccessTokenConverter()); return converter; } }关键点解析ClientDetailsServiceConfigurer这里我们使用了JdbcClientDetailsService它会自动按照OAuth2默认的Schema查询。如果你用了自定义表需要自己实现ClientDetailsService接口并在这里注入。AuthorizationServerEndpointsConfigurerauthenticationManager对于password授权模式是必须的因为它需要验证用户名和密码。userDetailsService在刷新令牌refresh_token时系统可能需要重新加载用户信息来生成新的访问令牌。tokenStore和accessTokenConverter这对组合指明了我们使用JWT。reuseRefreshTokens(false)建议设为false每次刷新都产生新的刷新令牌更安全。AuthorizationServerSecurityConfigurertokenKeyAccess(“permitAll()”)资源服务器需要获取公钥来验证JWT签名所以这个端点通常公开。checkTokenAccess(“isAuthenticated()”)校验令牌的端点需要客户端认证防止被滥用。allowFormAuthenticationForClients()非常重要这允许客户端在申请令牌时使用application/x-www-form-urlencoded格式在请求体中传递client_id和client_secret。否则你可能遇到401 Unauthorized错误提示“Full authentication is required to access this resource”。签名密钥示例中使用了对称密钥setSigningKey简单但不安全因为认证服务器和资源服务器需要共享同一个密钥。生产环境强烈推荐使用非对称加密RSA。认证服务器用私钥签名资源服务器用公钥验签公钥可以安全分发。3.3 自定义JWT令牌内容默认的JWT只包含一些标准字段如user_name,scope,exp等。我们经常需要把用户ID、部门等业务信息也放进去这样资源服务器拿到令牌就能直接使用无需再查用户库。/** * 自定义令牌增强器向JWT中添加额外信息 */ Component public class CustomTokenEnhancer implements TokenEnhancer { Override public OAuth2AccessToken enhance(OAuth2AccessToken accessToken, OAuth2Authentication authentication) { MapString, Object additionalInfo new HashMap(); // 从认证信息中获取用户详情 if (authentication.getPrincipal() instanceof UserDetails) { UserDetails userDetails (UserDetails) authentication.getPrincipal(); // 假设你的UserDetails实现类中有getUserId等方法 if (userDetails instanceof CustomUserDetails) { CustomUserDetails customUser (CustomUserDetails) userDetails; additionalInfo.put(user_id, customUser.getUserId()); additionalInfo.put(dept_code, customUser.getDeptCode()); } } // 添加一些通用信息 additionalInfo.put(issuer, my-auth-server); additionalInfo.put(version, 1.0); ((DefaultOAuth2AccessToken) accessToken).setAdditionalInformation(additionalInfo); return accessToken; } }然后在jwtAccessTokenConverter()方法中配置增强器链Bean public JwtAccessTokenConverter jwtAccessTokenConverter() { JwtAccessTokenConverter converter new JwtAccessTokenConverter(); converter.setSigningKey(my-signing-key); // 创建增强器链 TokenEnhancerChain enhancerChain new TokenEnhancerChain(); enhancerChain.setTokenEnhancers(Arrays.asList(customTokenEnhancer, converter)); // 注意顺序自定义增强器在前 endpoints.tokenEnhancer(enhancerChain); // 在endpoints配置中设置 return converter; } // 同时需要修改endpoints配置不再直接setAccessTokenConverter而是用tokenEnhancer Override public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception { endpoints .authenticationManager(authenticationManager) .userDetailsService(userDetailsService) .tokenStore(tokenStore()) .tokenEnhancer(tokenEnhancerChain()) // 使用增强器链 .reuseRefreshTokens(false); }这样生成的JWT令牌的Payload中就会包含我们自定义的user_id和dept_code字段了。4. 实操过程与核心环节实现配置完成后我们来实际运行并测试整个流程。这里以最常用的授权码模式 (authorization_code)和密码模式 (password)为例。4.1 授权码模式完整流程测试授权码模式是OAuth2中最安全、最完整的流程适用于有前端的Web应用。获取授权码 用户在浏览器访问授权URL会跳转到登录页由Spring Security提供或自定义。GET http://localhost:8080/oauth/authorize?client_idtest-clientresponse_typecodescopereadredirect_urihttp://localhost:8081/login/oauth2/code/teststatesome_stateclient_id: 我们在数据库配置的客户端ID。response_type: 固定为code。scope: 请求的权限范围。redirect_uri: 必须与数据库中配置的完全一致。state: 随机字符串用于防止CSRF攻击回调时会原样带回。用户登录并授权后浏览器会被重定向到redirect_uri并附带一个授权码如http://localhost:8081/login/oauth2/code/test?codeMCSBZvstatesome_state用授权码换取访问令牌 后端服务或前端通过后端代理用这个code去认证服务器换取access_token。curl -X POST http://localhost:8080/oauth/token \ -H “Content-Type: application/x-www-form-urlencoded” \ -d “grant_typeauthorization_codecodeMCSBZvredirect_urihttp://localhost:8081/login/oauth2/code/testclient_idtest-clientclient_secretsecret”注意client_secret是明文的secret不是数据库中加密后的。Spring Security OAuth2会在内部进行匹配验证。成功响应如下{ “access_token”: “eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...” “token_type”: “bearer” “refresh_token”: “eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...” “expires_in”: 7199, “scope”: “read” “user_id”: 123, “dept_code”: “IT” “jti”: “b0c6c8c0-5f5a-4b1a-9c9a-1e2b3c4d5e6f” }可以看到返回的access_token是一个JWT字符串并且包含了我们自定义的user_id和dept_code字段。4.2 密码模式直接获取令牌密码模式适用于高度信任的客户端如自家的移动端APP用户将用户名密码直接交给客户端客户端用它来换令牌。curl -X POST http://localhost:8080/oauth/token \ -u “test-client:secret” \ # HTTP Basic认证方式传递client_id和secret -H “Content-Type: application/x-www-form-urlencoded” \ -d “grant_typepasswordusernameadminpasswordadmin123scoperead”或者使用表单方式因为我们在安全配置中允许了curl -X POST http://localhost:8080/oauth/token \ -H “Content-Type: application/x-www-form-urlencoded” \ -d “grant_typepasswordusernameadminpasswordadmin123scopereadclient_idtest-clientclient_secretsecret”踩坑提醒密码模式需要配置authenticationManager并且你的UserDetailsService要能根据username加载出用户。同时确保客户端拥有password这个authorized_grant_types。4.3 使用刷新令牌获取新的访问令牌访问令牌过期后可以使用刷新令牌来获取一组新的令牌而无需用户再次登录。curl -X POST http://localhost:8080/oauth/token \ -H “Content-Type: application/x-www-form-urlencoded” \ -d “grant_typerefresh_tokenrefresh_tokenyour_refresh_tokenclient_idtest-clientclient_secretsecret”响应会返回新的access_token和refresh_token如果配置了reuseRefreshTokens(false)。5. 资源服务器配置与令牌校验认证服务器搭建好了令牌也发出了现在需要一个资源服务器来验证并使用这些令牌。资源服务器的配置相对简单。Configuration EnableResourceServer // 1. 启用资源服务器 public class ResourceServerConfig extends ResourceServerConfigurerAdapter { /** * 配置资源ID和令牌服务 */ Override public void configure(ResourceServerSecurityConfigurer resources) throws Exception { resources .resourceId(“order-service”) // 资源ID需与客户端配置的resource_ids匹配 .tokenStore(tokenStore()); // 必须与认证服务器使用相同的TokenStore或能解析JWT } /** * 配置HTTP请求的安全规则 */ Override public void configure(HttpSecurity http) throws Exception throws Exception { http .authorizeRequests() .antMatchers(“/api/orders/**”).authenticated() // /api/orders 下的接口需要认证 .antMatchers(“/api/public/**”).permitAll() // /api/public 下的接口公开 .anyRequest().authenticated(); // 其他所有请求需要认证 } Bean public TokenStore tokenStore() { // 如果是JWT且使用对称密钥直接新建一个同样的JwtTokenStore即可 // 如果是非对称密钥这里需要配置JwtAccessTokenConverter并设置验证用的公钥 JwtAccessTokenConverter converter new JwtAccessTokenConverter(); converter.setVerifierKey(“my-signing-key”); // 对称密钥与认证服务器相同 // 如果是非对称则用converter.setVerifierKey(publicKeyString); return new JwtTokenStore(converter); } }关键点资源服务器的tokenStore必须能够解析认证服务器颁发的令牌。对于对称密钥JWT直接设置相同的签名密钥即可。对于非对称密钥资源服务器只需要配置公钥进行验证。在受保护的接口中你可以通过SecurityContextHolder获取到认证信息并从中提取JWT中的自定义信息GetMapping(“/api/orders/my”) public ListOrder getMyOrders() { Authentication authentication SecurityContextHolder.getContext().getAuthentication(); MapString, Object details (MapString, Object) authentication.getDetails(); // 获取我们自定义的字段 Long userId (Long) details.get(“user_id”); String deptCode (String) details.get(“dept_code”); // ... 根据userId查询订单 }6. 常见问题与排查技巧实录在搭建和调试过程中你几乎一定会遇到下面这些问题。我把它们和解决方案整理成了速查表。问题现象可能原因排查步骤与解决方案401 Full authentication is required1. 客户端认证失败。2./oauth/token端点未允许表单认证。1. 检查client_id和client_secret是否正确数据库中的client_secret是否是加密后的但请求传的是明文原值。2. 在AuthorizationServerSecurityConfigurer中确认已调用.allowFormAuthenticationForClients()。Invalid redirect_uri重定向URI与数据库中注册的不匹配。检查请求中的redirect_uri参数是否与oauth_client_details表中web_server_redirect_uri字段的值完全一致包括协议、域名、端口和路径。Unsupported grant_type客户端未授权所使用的授权模式。检查oauth_client_details表中authorized_grant_types字段是否包含你正在使用的授权类型如password,authorization_code。Invalid grant_type请求的grant_type参数拼写错误或不被支持。检查grant_type参数值是否正确如password,authorization_code,refresh_token等。密码模式返回Bad credentials1. 用户名密码错误。2. 未配置authenticationManager。1. 确认用户名密码。2. 在AuthorizationServerEndpointsConfigurer中必须通过.authenticationManager(authenticationManager)注入AuthenticationManagerBean。JWT令牌在资源服务器无法解析认证服务器与资源服务器的令牌存储/签名密钥不一致。1. 对称密钥确保两边setSigningKey/setVerifierKey的值完全相同。2. 非对称密钥确保资源服务器配置的是正确的公钥。自定义字段在资源服务器获取不到JWT增强器配置顺序有误或资源服务器未正确解析。1. 确保在认证服务器自定义TokenEnhancer在JwtAccessTokenConverter之前加入到增强器链。2. 在资源服务器认证信息中的自定义字段在authentication.getDetails()中而非authentication.getPrincipal()。刷新令牌时报Invalid refresh token1. 刷新令牌已过期或无效。2. 刷新令牌时未提供client_id和client_secret。1. 检查数据库或令牌存储中该刷新令牌是否存在且未过期。2. 刷新令牌请求也必须进行客户端认证。我的独家避坑技巧善用/oauth/token_key和/oauth/check_token端点在开发阶段将tokenKeyAccess和checkTokenAccess都设为permitAll()方便你直接通过浏览器或Postman查看JWT的公钥和校验令牌内容这是调试的利器。为不同环境准备不同配置使用Spring Profile为开发、测试、生产环境配置不同的签名密钥、客户端信息和令牌有效期。绝对不要将生产环境的密钥提交到代码仓库。客户端密钥管理客户端密钥client_secret应定期轮换。可以实现一个后台管理功能允许生成新的密钥并让客户端在旧密钥过期前完成切换。数据库中始终存储BCrypt加密后的密文。作用域Scope的设计scope不要设计得太细或太粗。可以按照业务模块划分如order:read,order:write,user:profile。在资源服务器上可以使用PreAuthorize(“#oauth2.hasScope(‘order:read’)”)进行细粒度控制。关于JWT令牌吊销如果确实需要实现吊销可以维护一个令牌IDJTI的黑名单存入Redis并设置过期时间。资源服务器在验证令牌签名后额外增加一步检查Redis黑名单。虽然增加了网络IO但比查数据库要轻量得多。搭建一个生产可用的OAuth2认证服务配置只是第一步。后续还需要考虑高可用、监控、审计日志、与现有用户系统集成等诸多问题。但通过本篇实战你已经掌握了最核心的骨架和内脏。记住理解每一个配置项背后的“为什么”远比记住配置本身更重要。当你遇到问题时回头看看OAuth2的协议流程和Spring Security OAuth2的配置模型思路往往会清晰起来。