简介:这套源码面向需要在Java环境里落地OAuth 2.0授权服务的开发者,以Maven和Apache OLTU库为基础,完整实现授权服务器与资源服务器,并把JWT作为访问令牌,覆盖授权码模式、令牌刷新、资源访问等核心流程。压缩包共221个文件,以176个Java源码为主,另有22个XML配置及依赖清单、少量脚本,整体仅355KB,目录结构紧凑,便于导入工程直接阅读。已有2184人学习浏览。源码包含授权服务器配置、资源服务器配置、认证提供者、访问令牌生成器等关键类,从授权端点、令牌端点到刷新令牌端点均有明确实现,并附客户端请求工具及测试代码,可对照理解角色交互和令牌生命周期。项目对密码字段采用MD5处理,适合中高级开发者参考其安全取舍;结合JWT的头部、负载与签名结构,也能为无状态鉴权设计提供直接示例。1. OAuth2.0 + JWT:一套代码跑通的授权服务端与客户端闭环很多项目只教授权服务器怎么写,客户端却在文档里一句“请参考第三方SDK”带过。但这套源码把服务端与客户端都实现完整:从登录跳转、授权码回调、令牌换取,到携带 JWT 访问资源接口,整条链路在一个工程里就能跑起来,调试时能同时看到两端的日志,这是它区别于一般 Demo 的最大价值。项目适合两类人:一类是被 OAuth2.0 流程绕晕的入门者,可以直接把请求在两端之间的传递过程看一遍;另一类是想在自研系统里落地单点登录授权的后端开发,需要一个能改的基座而不是一堆碎片配置。接下来我会从工程拆分一路讲到二次开发,按真实动手顺序走一遍。2. 工程拆分与依赖选型:server 与 client 各管什么2.1 两个模块的职责边界拿到源码后先不用急着看代码,打开工程根目录就能摸清整体布局。工程按职责分成三个 Maven 模块,目录结构大概是:oauth2-jwt-demo ├── oauth-server │ ├── src/main/java │ │ └── com/example/oauthserver │ │ ├── config/AuthorizationServerConfig.java │ │ ├── config/ResourceServerConfig.java │ │ ├── controller/UserController.java │ │ └── service/UserService.java │ └── src/main/resources/application.yml ├── oauth-client │ ├── src/main/java │ │ └── com/example/oauthclient │ │ ├── config/OAuth2ClientConfig.java │ │ ├── controller/CallbackController.java │ │ └── interceptor/BearerTokenInterceptor.java │ └── src/main/resources/application.yml └── pom.xml模块划分的意义在于调试时日志不会混在一起。我一般会先把两个应用分别启动,什么都不配,先看默认端口有没有冲突。服务端建议用 8080,客户端用 8081,这是最常见的本地联调组合。如果你的压缩包没有写明端口,直接看 application.yml 里的server.port即可。2.2 依赖清单里值得留心的点服务端和客户端的功能边界从依赖上也能看出来。服务端模块在 Spring Boot 2.x 下通常会这样声明:<parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.7.18</version> </parent> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.security.oauth</groupId> <artifactId>spring-security-oauth2</artifactId> <version>2.5.2.RELEASE</version> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-api</artifactId> <version>0.11.5</version> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-impl</artifactId> <version>0.11.5</version> <scope>runtime</scope> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-jackson</artifactId> <version>0.11.5</version> <scope>runtime</scope> </dependency> </dependencies>第一处注意点是spring-security-oauth2的版本。老项目常用的 2.3.x 版本在 Spring Security 5.4 之后会出现兼容问题,一般我会直接锁 2.5.2.RELEASE,它在当前 Boot 2.7 下表现稳定。第二处是 JJWT 分成三个包,api 只在编译期出现,impl 和 jackson 走 runtime,这是为了避免底层实现和 JSON 序列化包被重复打包。客户端模块的依赖轻很多,它不需要完整的服务端能力,只需引入spring-boot-starter-web和spring-boot-starter-security,再配合spring-security-oauth2中的客户端类即可,后面会讲到具体写法。2.3 数据表设计:一张 client_details 就够起步授权服务端涉及的数据量并不大,项目里通常只需要两张表:一张oauth_client_details存客户端注册信息,一张sys_user存登录用户。下面以客户端表为例,表结构如下:CREATE TABLE oauth_client_details ( client_id VARCHAR(64) PRIMARY KEY, client_secret VARCHAR(128) NOT NULL, resource_ids VARCHAR(256), scope VARCHAR(256), authorized_grant_types VARCHAR(256), web_server_redirect_uri VARCHAR(256), access_token_validity INTEGER DEFAULT 7200, refresh_token_validity INTEGER DEFAULT 2592000 );字段里最关键的是authorized_grant_types。项目演示的授权码模式要求该字段至少包含authorization_code和refresh_token;如果只写了implicit,后面请求令牌时会出现unsupported_grant_type异常。另外web_server_redirect_uri必须和客户端模块里的回调地址完全一致,端口差一个都会跳转失败。服务端如果配置的是本地 MySQL,启动时可以通过初始化 SQL 建表。想省事也可以临时换成 H2 内存库,把连接串改成jdbc:h2:mem:testdb,但注意重启后客户端注册数据会丢失,每次都要重新执行初始化脚本。3. 服务端核心链路:授权码到 JWT 的生产全过程3.1 五段式配置:AuthorizationServerConfigurerAdapter服务端模块的心脏是AuthorizationServerConfig,它继承AuthorizationServerConfigurerAdapter。这个适配器提供五个扩展点,项目里按功能拆开写:@Configuration @EnableAuthorizationServer public class AuthorizationServerConfig extends AuthorizationServerConfigurerAdapter { @Autowired private DataSource dataSource; @Autowired private AuthenticationManager authenticationManager; @Autowired private UserDetailsService userDetailsService; @Bean public JwtAccessTokenConverter accessTokenConverter() { JwtAccessTokenConverter converter = new JwtAccessTokenConverter(); converter.setSigningKey("demo-sign-key-change-me"); return converter; } @Bean public TokenStore tokenStore() { return new JwtTokenStore(accessTokenConverter()); } @Override public void configure(ClientDetailsServiceConfigurer clients) throws Exception { clients.jdbc(dataSource) .passwordEncoder(new BCryptPasswordEncoder()); } @Override public void configure(AuthorizationServerSecurityConfigurer security) { security.tokenKeyAccess("permitAll()") .checkTokenAccess("isAuthenticated()"); } @Override public void configure(AuthorizationServerEndpointsConfigurer endpoints) { endpoints.tokenStore(tokenStore()) .authenticationManager(authenticationManager) .userDetailsService(userDetailsService) .accessTokenConverter(accessTokenConverter()) .tokenEnhancer(customTokenEnhancer()); } }先看ClientDetailsServiceConfigurer:clients.jdbc(dataSource)告诉框架从oauth_client_details表加载客户端,密码用 BCrypt 加密。数据库里的client_secret存的是 BCrypt 密文,不是明文,这是很多第一次跑通 OAuth 的人也会踩的坑——拿明文 secret 去请求令牌,一直报invalid_client。再看AuthorizationServerEndpointsConfigurer:authenticationManager必须传,授权码模式下用户登录后的认证结果需要它做二次确认;userDetailsService则是刷新令牌时重新加载用户信息的入口。tokenStore用的是 JwtTokenStore,它本身不存数据,只把 JWT 字符串解析回OAuth2AccessToken对象,真正的状态都在令牌里,所以服务端不需要统一保存会话。tokenEnhancer挂的是自定义增强器,后面会具体展开。这里要提醒的是:如果你同时配置了accessTokenConverter和自定义TokenEnhancer,两者的调用顺序由 Spring 组装链决定,增强器先运行,再交给转换器签名;反过来写会拿不到增强器塞进去的附加参数。3.2 授权码的一生:从 authorize 到 callback授权码模式的完整调用链是:客户端把用户引导到/oauth/authorize?response_type=code&client_id=demo-client&redirect_uri=http://localhost:8081/callback&scope=read,服务端先弹登录页,登录成功后生成授权码并回跳到回调地址。授权码生成过程在 Spring Security OAuth2 里已被封装,代码路径在AuthorizationEndpoint。为了看清它,我一般会给TokenEndpoint加一个日志过滤器,打印每次请求的参数:@Component public class OAuthRequestLogFilter extends OncePerRequestFilter { @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { if (request.getRequestURI().contains("/oauth/")) { String params = request.getParameterMap().entrySet().stream() .map(e -> e.getKey() + "=" + String.join(",", e.getValue())) .collect(Collectors.joining("&")); logger.info("OAuth request -> " + request.getRequestURI() + "?" + params); } filterChain.doFilter(request, response); } }这个过滤器没有业务逻辑,但调试时极有用。它能让你看到授权码模式下客户端到底传了几个参数、scope 是什么、有没有把state带上。state参数是防 CSRF 的关键,官方客户端会自动生成,自己实现的客户端必须手动生成并校验。授权码生成之后默认有效期是 1 分钟,且只能消费一次。服务端通过AuthorizationCodeServices存储授权码,默认基于内存;二次开发如果想做多节点部署,我会把它替换成 Redis 实现,把AuthorizationCodeServices子类改成读取RedisOperations即可。3.3 JWT 令牌生成:增强器附加声明并交给转换器签名服务端发令牌的入口是/oauth/token,在授权码模式下拿code换access_token。Spring Security OAuth2 默认生成的 JWT 只包含sub、iat、exp、client_id等标准声明,业务上往往不够用,所以源码里带了一个CustomTokenEnhancer:public class CustomTokenEnhancer implements TokenEnhancer { @Override public OAuth2AccessToken enhance(OAuth2AccessToken accessToken, OAuth2Authentication authentication) { final Map<String, Object> additionalInfo = new HashMap<>(); String userName = authentication.getName(); additionalInfo.put("username", userName); additionalInfo.put("source", "oauth-server"); additionalInfo.put("tenant_id", "default"); // 从认证信息中提取用户实体,填充到声明里 if (authentication.getPrincipal() instanceof UserDetails) { UserDetails user = (UserDetails) authentication.getPrincipal(); additionalInfo.put("authorities", user.getAuthorities()); } ((DefaultOAuth2AccessToken) accessToken).setAdditionalInformation(additionalInfo); return accessToken; } }这里的关键是把DefaultOAuth2AccessToken的additionalInformation设置到 JWT payload 中,最终由JwtAccessTokenConverter加密签名后输出。你会在令牌串中间那段 payload 里看到username和authorities。JwtAccessTokenConverter默认使用 HMAC-SHA256 对称签名,setSigningKey设置的字符串就是共享密钥。这个密钥在资源服务器验证签名时还得再用一次,两端必须完全一致。项目里放在application.yml的jwt.signing-key配置下,我一般会建议把它挪到环境变量里,避免写死在代码仓库。注意:JWT 的 payload 只是 Base64 编码,并非加密,任何拿到密钥的客户端都能直接解码查看声明内容,敏感信息不要往这里塞。4. 客户端接入实现:回调换令牌与携带令牌访问资源4.1 客户端的跳转逻辑客户端模块本质上是另一个 Spring Boot 应用,它把用户重定向到服务端。第一步是配置客户端的基本信息:@Configuration public class OAuth2ClientConfig { @Bean public AuthorizationCodeResourceDetails resourceDetails() { AuthorizationCodeResourceDetails details = new AuthorizationCodeResourceDetails(); details.setId("oauth2-client"); details.setClientId("demo-client"); details.setClientSecret("demo-secret"); details.setAccessTokenUri("http://localhost:8080/oauth/token"); details.setUserAuthorizationUri("http://localhost:8080/oauth/authorize"); details.setScope(Arrays.asList("read", "write")); details.setUseCurrentUri(false); details.setRedirectUri("http://localhost:8081/login/oauth2/code/oauth-server"); return details; } }setUseCurrentUri(false)是容易忽略的点。默认情况下客户端会把当前请求的 URI 作为回调,这在经过网关转发时会出问题;显式设置redirectUri后,无论从哪个入口发起跳转,回调地址都固定且与服务端注册的一致。4.2 回调接口:授权码换令牌用户授权完成后,服务端把code拼在重定向 URL 上,回到客户端的回调接口。这里要做两件事:校验state、用code换令牌。@RestController public class CallbackController { @Autowired private OAuth2RestTemplate oauth2RestTemplate; @GetMapping("/login/oauth2/code/oauth-server") public String callback(@RequestParam("code") String code, @RequestParam("state") String state) { // 校验 state 与发起跳转时保存的值是否一致 if (!"expected-state".equals(state)) { return "state 校验失败"; } // 底层会自动调用 /oauth/token 完成 code -> token 的交换 OAuth2AccessToken token = oauth2RestTemplate.getAccessToken(); return "拿到令牌: " + token.getValue(); } }OAuth2RestTemplate是 Spring Security OAuth2 提供的封装好的令牌管理类。它内部维护了一个AccessTokenProvider,调用getAccessToken()时如果发现当前没有有效令牌,会自动携带code与redirect_uri去服务端交换。这个类的设计有一个“后悔药”:服务端如果配置了refresh_token授权类型,OAuth2RestTemplate会在令牌过期后自动用刷新令牌续期,业务方无需关心续期逻辑。4.3 资源服务器验证 JWT拿到令牌后,客户端要请求服务端受保护接口,此时资源服务器的 JWT 解码就派上用场了。资源服务器配置长这样:@Configuration @EnableResourceServer public class ResourceServerConfig extends ResourceServerConfigurerAdapter { @Override public void configure(ResourceServerSecurityConfigurer resources) { resources.tokenServices(tokenServices()); } @Bean public ResourceServerTokenServices tokenServices() { DefaultTokenServices services = new DefaultTokenServices(); services.setTokenStore(new JwtTokenStore(jwtAccessTokenConverter())); services.setSupportRefreshToken(true); return services; } @Bean public JwtAccessTokenConverter jwtAccessTokenConverter() { JwtAccessTokenConverter converter = new JwtAccessTokenConverter(); converter.setSigningKey("demo-sign-key-change-me"); // 必须与服务端一致 return converter; } }这里面的JwtAccessTokenConverter使用同一个签名密钥,所以资源服务器不需要远程调用服务端的 check_token 接口,直接本地解析 JWT 签名即完成验证。生产环境更紧实的做法是非对称加密:服务端用私钥签名,资源服务器配置公钥验签,签名密钥不通过网络传输。若要改成非对称方式,替换JwtAccessTokenConverter的setKeyPair配置,并在资源服务器设置对应的公钥文件即可。请求资源接口时,客户端只需要在请求头带Authorization: Bearer <token>。我给客户端写了一个简单的 RestTemplate 拦截器:public class BearerTokenInterceptor implements ClientHttpRequestInterceptor { private final Supplier<String> tokenSupplier; public BearerTokenInterceptor(Supplier<String> tokenSupplier) { this.tokenSupplier = tokenSupplier; } @Override public ClientHttpResponse intercept(HttpRequest request, byte[] body, ClientHttpRequestExecution execution) throws IOException { request.getHeaders().setBearerAuth(tokenSupplier.get()); return execution.execute(request, body); } }注册拦截器后,客户端每次请求后端服务都会自动带上令牌。这里要注意tokenSupplier返回的必须是还在有效期内的值,否则每次都要先通过OAuth2RestTemplate.getAccessToken()主动刷新一次,再把新令牌注入Supplier。5. 避坑:授权流程中常见的六个翻车现场5.1 提示 invalid_client 但账号明明是对的现象:POST /oauth/token返回error="invalid_client",client_id 和 client_secret 核对过没有拼写错误。原因:数据库里的client_secret存的是 BCrypt 加密后的密文,而 Spring 的ClientDetailsService默认用明文比较。项目的AuthorizationServerConfig里如果漏了passwordEncoder配置,就会一直报这个错。解决:检查AuthorizationServerConfigurerAdapter.configure(ClientDetailsServiceConfigurer)里有没有指定passwordEncoder。没有就补上:clients.jdbc(dataSource).passwordEncoder(new BCryptPasswordEncoder());5.2 回调地址对不上导致无限重定向现象:用户登录成功之后,浏览器一直在授权页和回调页之间来回跳,最终页面报redirect_uri_mismatch。原因:oauth_client_details.web_server_redirect_uri里配置的是http://localhost:8081/callback,客户端实际回调写的是http://127.0.0.1:8081/callback,Spring 对 URI 做精确匹配,localhost 与 127.0.0.1 视作不同地址。解决:把两端配置改成完全一致,最好都用域名而不是 IP;同时检查客户端模块setRedirectUri有没有显式设置,未设置时框架会用当前请求的完整地址作为回调,动态拼接的地址很容易触发 mismatch。5.3 JWT 签名验证报 SignatureException现象:资源服务器访问受保护接口时抛异常,日志里出现JWT signature does not match locally computed signature。原因:JwtAccessTokenConverter在服务端用的签名密钥是demo-sign-key-change-me,资源服务器配置成了别的值;或者有一方经过配置中心覆盖了密钥。解决:把签名密钥抽成环境变量或配置项,服务端与资源服务器引用同一个 key。我一般还会在测试环境固定一个校验逻辑:解码 JWT 头,看alg字段,若为HS256,确认两端共享密钥一致即可。5.4 刷新令牌返回 null 或提示 unauthorized现象:access_token 过期后自动刷新,OAuth2RestTemplate抛异常,提示 refresh token 无效。原因:JWT 模式不保存服务端会话,刷新令牌的续期逻辑依赖 refresh_token 里的client_id和user_name信息。如果客户端注册时的authorized_grant_types缺少refresh_token,换取令牌时服务端根本不会返回刷新令牌。解决:在oauth_client_details表中为authorized_grant_types补齐authorization_code,refresh_token;并确认refresh_token_validity设置得比 access_token 的有效期长。5.5 scope 不一致导致 403 而不是 401现象:令牌明明有效,请求资源接口时返回 403,日志提示Access is denied。原因:客户端申请的 scope 是read,但资源接口的方法上标注的是#oauth2.hasAnyScope('write'),令牌里的 scope 列表与接口要求不一致,此时 Spring 返回 403。解决:查看 JWT payload 里的scope字段,确认客户端申请时的 scope。不要只看代码里标注了什么,以令牌实际携带的信息为准。5.6 Spring Security 版本兼容问题现象:更换到 Spring Boot 2.7.18 后,@EnableAuthorizationServer报错找不到注解。原因:Spring 官方从 Boot 2.7 开始将 spring-security-oauth2 标记为 deprecated 并迁移到 spring-authorization-server,旧的AuthorizationServerConfigurerAdapter在新版本中不再自动加载。解决:有两条路线。留在 Boot 2.7.x 并把依赖锁定在2.5.2.RELEASE,完全不用改动代码;或者升级到 Boot 3.x + spring-authorization-server,那相当于重写授权服务端配置。学习项目用前者足够,生产若想升级则要做好大量适配工作。6. 二次开发思路:多客户端注册与自定义 JWT 载荷6.1 多客户端注册与动态密钥加载项目默认只注册了一个demo-client,真实环境至少要区分内部后台和移动端。多客户端的关键是在oauth_client_details表插入新记录,给每个客户端配独立的 scope 和回调地址。配置已经走 jdbc 动态读取,所以不需要改代码。若想让不同客户端使用不同签名密钥,需要自定义JwtAccessTokenConverter的SigningKeyResolver,让它根据 JWT 头里的kid字段查询对应密钥。这是把单密钥升级为多密钥的常用手法,也便于后续做密钥轮换。6.2 自定义声明到资源侧的落地令牌里的username、authorities、tenant_id在资源侧不应该是黑匣子。我一般做法是写一个ThreadLocal工具类,在接口拦截器里解析 payload 存入ThreadLocal,接口处理完成后再 remove,避免线程池复用造成数据串台。做完这一步,Controller 就可以直接读tenant_id做数据隔离,同一套资源服务同时服务多个租户不再是难事。6.3 改造后的验证路线扩展多客户端或自定义声明后,按四步验证:重启服务端,确认/oauth/authorize能正常打开;用新 client_id 走一遍授权码流程,看回调是否成功;随后请求/oauth/token,解码新的 JWT 检查新增声明;最后请求资源接口,确认资源侧能读到新增字段。四步走完,绝大多数改动在十分钟内就能回归。从那以后我做任何 OAuth 改造,都会强制把“解码 JWT 看声明”作为第一步,而不是盲改配置,能省下不少排查时间,希望帮到你。本文还有配套的精品资源,点击获取
简介:这套源码面向需要在Java环境里落地OAuth 2.0授权服务的开发者,以Maven和Apache OLTU库为基础,完整实现授权服务器与资源服务器,并把JWT作为访问令牌,覆盖授权码模式、令牌刷新、资源访问等核心流程。压缩包共221个文件,以176个Java源码为主,另有22个XML配置及依赖清单、少量脚本,整体仅355KB,目录结构紧凑,便于导入工程直接阅读。已有2184人学习浏览。源码包含授权服务器配置、资源服务器配置、认证提供者、访问令牌生成器等关键类,从授权端点、令牌端点到刷新令牌端点均有明确实现,并附客户端请求工具及测试代码,可对照理解角色交互和令牌生命周期。项目对密码字段采用MD5处理,适合中高级开发者参考其安全取舍;结合JWT的头部、负载与签名结构,也能为无状态鉴权设计提供直接示例。1. OAuth2.0 + JWT:一套代码跑通的授权服务端与客户端闭环很多项目只教授权服务器怎么写,客户端却在文档里一句“请参考第三方SDK”带过。但这套源码把服务端与客户端都实现完整:从登录跳转、授权码回调、令牌换取,到携带 JWT 访问资源接口,整条链路在一个工程里就能跑起来,调试时能同时看到两端的日志,这是它区别于一般 Demo 的最大价值。项目适合两类人:一类是被 OAuth2.0 流程绕晕的入门者,可以直接把请求在两端之间的传递过程看一遍;另一类是想在自研系统里落地单点登录授权的后端开发,需要一个能改的基座而不是一堆碎片配置。接下来我会从工程拆分一路讲到二次开发,按真实动手顺序走一遍。2. 工程拆分与依赖选型:server 与 client 各管什么2.1 两个模块的职责边界拿到源码后先不用急着看代码,打开工程根目录就能摸清整体布局。工程按职责分成三个 Maven 模块,目录结构大概是:oauth2-jwt-demo ├── oauth-server │ ├── src/main/java │ │ └── com/example/oauthserver │ │ ├── config/AuthorizationServerConfig.java │ │ ├── config/ResourceServerConfig.java │ │ ├── controller/UserController.java │ │ └── service/UserService.java │ └── src/main/resources/application.yml ├── oauth-client │ ├── src/main/java │ │ └── com/example/oauthclient │ │ ├── config/OAuth2ClientConfig.java │ │ ├── controller/CallbackController.java │ │ └── interceptor/BearerTokenInterceptor.java │ └── src/main/resources/application.yml └── pom.xml模块划分的意义在于调试时日志不会混在一起。我一般会先把两个应用分别启动,什么都不配,先看默认端口有没有冲突。服务端建议用 8080,客户端用 8081,这是最常见的本地联调组合。如果你的压缩包没有写明端口,直接看 application.yml 里的server.port即可。2.2 依赖清单里值得留心的点服务端和客户端的功能边界从依赖上也能看出来。服务端模块在 Spring Boot 2.x 下通常会这样声明:<parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.7.18</version> </parent> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.security.oauth</groupId> <artifactId>spring-security-oauth2</artifactId> <version>2.5.2.RELEASE</version> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-api</artifactId> <version>0.11.5</version> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-impl</artifactId> <version>0.11.5</version> <scope>runtime</scope> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-jackson</artifactId> <version>0.11.5</version> <scope>runtime</scope> </dependency> </dependencies>第一处注意点是spring-security-oauth2的版本。老项目常用的 2.3.x 版本在 Spring Security 5.4 之后会出现兼容问题,一般我会直接锁 2.5.2.RELEASE,它在当前 Boot 2.7 下表现稳定。第二处是 JJWT 分成三个包,api 只在编译期出现,impl 和 jackson 走 runtime,这是为了避免底层实现和 JSON 序列化包被重复打包。客户端模块的依赖轻很多,它不需要完整的服务端能力,只需引入spring-boot-starter-web和spring-boot-starter-security,再配合spring-security-oauth2中的客户端类即可,后面会讲到具体写法。2.3 数据表设计:一张 client_details 就够起步授权服务端涉及的数据量并不大,项目里通常只需要两张表:一张oauth_client_details存客户端注册信息,一张sys_user存登录用户。下面以客户端表为例,表结构如下:CREATE TABLE oauth_client_details ( client_id VARCHAR(64) PRIMARY KEY, client_secret VARCHAR(128) NOT NULL, resource_ids VARCHAR(256), scope VARCHAR(256), authorized_grant_types VARCHAR(256), web_server_redirect_uri VARCHAR(256), access_token_validity INTEGER DEFAULT 7200, refresh_token_validity INTEGER DEFAULT 2592000 );字段里最关键的是authorized_grant_types。项目演示的授权码模式要求该字段至少包含authorization_code和refresh_token;如果只写了implicit,后面请求令牌时会出现unsupported_grant_type异常。另外web_server_redirect_uri必须和客户端模块里的回调地址完全一致,端口差一个都会跳转失败。服务端如果配置的是本地 MySQL,启动时可以通过初始化 SQL 建表。想省事也可以临时换成 H2 内存库,把连接串改成jdbc:h2:mem:testdb,但注意重启后客户端注册数据会丢失,每次都要重新执行初始化脚本。3. 服务端核心链路:授权码到 JWT 的生产全过程3.1 五段式配置:AuthorizationServerConfigurerAdapter服务端模块的心脏是AuthorizationServerConfig,它继承AuthorizationServerConfigurerAdapter。这个适配器提供五个扩展点,项目里按功能拆开写:@Configuration @EnableAuthorizationServer public class AuthorizationServerConfig extends AuthorizationServerConfigurerAdapter { @Autowired private DataSource dataSource; @Autowired private AuthenticationManager authenticationManager; @Autowired private UserDetailsService userDetailsService; @Bean public JwtAccessTokenConverter accessTokenConverter() { JwtAccessTokenConverter converter = new JwtAccessTokenConverter(); converter.setSigningKey("demo-sign-key-change-me"); return converter; } @Bean public TokenStore tokenStore() { return new JwtTokenStore(accessTokenConverter()); } @Override public void configure(ClientDetailsServiceConfigurer clients) throws Exception { clients.jdbc(dataSource) .passwordEncoder(new BCryptPasswordEncoder()); } @Override public void configure(AuthorizationServerSecurityConfigurer security) { security.tokenKeyAccess("permitAll()") .checkTokenAccess("isAuthenticated()"); } @Override public void configure(AuthorizationServerEndpointsConfigurer endpoints) { endpoints.tokenStore(tokenStore()) .authenticationManager(authenticationManager) .userDetailsService(userDetailsService) .accessTokenConverter(accessTokenConverter()) .tokenEnhancer(customTokenEnhancer()); } }先看ClientDetailsServiceConfigurer:clients.jdbc(dataSource)告诉框架从oauth_client_details表加载客户端,密码用 BCrypt 加密。数据库里的client_secret存的是 BCrypt 密文,不是明文,这是很多第一次跑通 OAuth 的人也会踩的坑——拿明文 secret 去请求令牌,一直报invalid_client。再看AuthorizationServerEndpointsConfigurer:authenticationManager必须传,授权码模式下用户登录后的认证结果需要它做二次确认;userDetailsService则是刷新令牌时重新加载用户信息的入口。tokenStore用的是 JwtTokenStore,它本身不存数据,只把 JWT 字符串解析回OAuth2AccessToken对象,真正的状态都在令牌里,所以服务端不需要统一保存会话。tokenEnhancer挂的是自定义增强器,后面会具体展开。这里要提醒的是:如果你同时配置了accessTokenConverter和自定义TokenEnhancer,两者的调用顺序由 Spring 组装链决定,增强器先运行,再交给转换器签名;反过来写会拿不到增强器塞进去的附加参数。3.2 授权码的一生:从 authorize 到 callback授权码模式的完整调用链是:客户端把用户引导到/oauth/authorize?response_type=code&client_id=demo-client&redirect_uri=http://localhost:8081/callback&scope=read,服务端先弹登录页,登录成功后生成授权码并回跳到回调地址。授权码生成过程在 Spring Security OAuth2 里已被封装,代码路径在AuthorizationEndpoint。为了看清它,我一般会给TokenEndpoint加一个日志过滤器,打印每次请求的参数:@Component public class OAuthRequestLogFilter extends OncePerRequestFilter { @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { if (request.getRequestURI().contains("/oauth/")) { String params = request.getParameterMap().entrySet().stream() .map(e -> e.getKey() + "=" + String.join(",", e.getValue())) .collect(Collectors.joining("&")); logger.info("OAuth request -> " + request.getRequestURI() + "?" + params); } filterChain.doFilter(request, response); } }这个过滤器没有业务逻辑,但调试时极有用。它能让你看到授权码模式下客户端到底传了几个参数、scope 是什么、有没有把state带上。state参数是防 CSRF 的关键,官方客户端会自动生成,自己实现的客户端必须手动生成并校验。授权码生成之后默认有效期是 1 分钟,且只能消费一次。服务端通过AuthorizationCodeServices存储授权码,默认基于内存;二次开发如果想做多节点部署,我会把它替换成 Redis 实现,把AuthorizationCodeServices子类改成读取RedisOperations即可。3.3 JWT 令牌生成:增强器附加声明并交给转换器签名服务端发令牌的入口是/oauth/token,在授权码模式下拿code换access_token。Spring Security OAuth2 默认生成的 JWT 只包含sub、iat、exp、client_id等标准声明,业务上往往不够用,所以源码里带了一个CustomTokenEnhancer:public class CustomTokenEnhancer implements TokenEnhancer { @Override public OAuth2AccessToken enhance(OAuth2AccessToken accessToken, OAuth2Authentication authentication) { final Map<String, Object> additionalInfo = new HashMap<>(); String userName = authentication.getName(); additionalInfo.put("username", userName); additionalInfo.put("source", "oauth-server"); additionalInfo.put("tenant_id", "default"); // 从认证信息中提取用户实体,填充到声明里 if (authentication.getPrincipal() instanceof UserDetails) { UserDetails user = (UserDetails) authentication.getPrincipal(); additionalInfo.put("authorities", user.getAuthorities()); } ((DefaultOAuth2AccessToken) accessToken).setAdditionalInformation(additionalInfo); return accessToken; } }这里的关键是把DefaultOAuth2AccessToken的additionalInformation设置到 JWT payload 中,最终由JwtAccessTokenConverter加密签名后输出。你会在令牌串中间那段 payload 里看到username和authorities。JwtAccessTokenConverter默认使用 HMAC-SHA256 对称签名,setSigningKey设置的字符串就是共享密钥。这个密钥在资源服务器验证签名时还得再用一次,两端必须完全一致。项目里放在application.yml的jwt.signing-key配置下,我一般会建议把它挪到环境变量里,避免写死在代码仓库。注意:JWT 的 payload 只是 Base64 编码,并非加密,任何拿到密钥的客户端都能直接解码查看声明内容,敏感信息不要往这里塞。4. 客户端接入实现:回调换令牌与携带令牌访问资源4.1 客户端的跳转逻辑客户端模块本质上是另一个 Spring Boot 应用,它把用户重定向到服务端。第一步是配置客户端的基本信息:@Configuration public class OAuth2ClientConfig { @Bean public AuthorizationCodeResourceDetails resourceDetails() { AuthorizationCodeResourceDetails details = new AuthorizationCodeResourceDetails(); details.setId("oauth2-client"); details.setClientId("demo-client"); details.setClientSecret("demo-secret"); details.setAccessTokenUri("http://localhost:8080/oauth/token"); details.setUserAuthorizationUri("http://localhost:8080/oauth/authorize"); details.setScope(Arrays.asList("read", "write")); details.setUseCurrentUri(false); details.setRedirectUri("http://localhost:8081/login/oauth2/code/oauth-server"); return details; } }setUseCurrentUri(false)是容易忽略的点。默认情况下客户端会把当前请求的 URI 作为回调,这在经过网关转发时会出问题;显式设置redirectUri后,无论从哪个入口发起跳转,回调地址都固定且与服务端注册的一致。4.2 回调接口:授权码换令牌用户授权完成后,服务端把code拼在重定向 URL 上,回到客户端的回调接口。这里要做两件事:校验state、用code换令牌。@RestController public class CallbackController { @Autowired private OAuth2RestTemplate oauth2RestTemplate; @GetMapping("/login/oauth2/code/oauth-server") public String callback(@RequestParam("code") String code, @RequestParam("state") String state) { // 校验 state 与发起跳转时保存的值是否一致 if (!"expected-state".equals(state)) { return "state 校验失败"; } // 底层会自动调用 /oauth/token 完成 code -> token 的交换 OAuth2AccessToken token = oauth2RestTemplate.getAccessToken(); return "拿到令牌: " + token.getValue(); } }OAuth2RestTemplate是 Spring Security OAuth2 提供的封装好的令牌管理类。它内部维护了一个AccessTokenProvider,调用getAccessToken()时如果发现当前没有有效令牌,会自动携带code与redirect_uri去服务端交换。这个类的设计有一个“后悔药”:服务端如果配置了refresh_token授权类型,OAuth2RestTemplate会在令牌过期后自动用刷新令牌续期,业务方无需关心续期逻辑。4.3 资源服务器验证 JWT拿到令牌后,客户端要请求服务端受保护接口,此时资源服务器的 JWT 解码就派上用场了。资源服务器配置长这样:@Configuration @EnableResourceServer public class ResourceServerConfig extends ResourceServerConfigurerAdapter { @Override public void configure(ResourceServerSecurityConfigurer resources) { resources.tokenServices(tokenServices()); } @Bean public ResourceServerTokenServices tokenServices() { DefaultTokenServices services = new DefaultTokenServices(); services.setTokenStore(new JwtTokenStore(jwtAccessTokenConverter())); services.setSupportRefreshToken(true); return services; } @Bean public JwtAccessTokenConverter jwtAccessTokenConverter() { JwtAccessTokenConverter converter = new JwtAccessTokenConverter(); converter.setSigningKey("demo-sign-key-change-me"); // 必须与服务端一致 return converter; } }这里面的JwtAccessTokenConverter使用同一个签名密钥,所以资源服务器不需要远程调用服务端的 check_token 接口,直接本地解析 JWT 签名即完成验证。生产环境更紧实的做法是非对称加密:服务端用私钥签名,资源服务器配置公钥验签,签名密钥不通过网络传输。若要改成非对称方式,替换JwtAccessTokenConverter的setKeyPair配置,并在资源服务器设置对应的公钥文件即可。请求资源接口时,客户端只需要在请求头带Authorization: Bearer <token>。我给客户端写了一个简单的 RestTemplate 拦截器:public class BearerTokenInterceptor implements ClientHttpRequestInterceptor { private final Supplier<String> tokenSupplier; public BearerTokenInterceptor(Supplier<String> tokenSupplier) { this.tokenSupplier = tokenSupplier; } @Override public ClientHttpResponse intercept(HttpRequest request, byte[] body, ClientHttpRequestExecution execution) throws IOException { request.getHeaders().setBearerAuth(tokenSupplier.get()); return execution.execute(request, body); } }注册拦截器后,客户端每次请求后端服务都会自动带上令牌。这里要注意tokenSupplier返回的必须是还在有效期内的值,否则每次都要先通过OAuth2RestTemplate.getAccessToken()主动刷新一次,再把新令牌注入Supplier。5. 避坑:授权流程中常见的六个翻车现场5.1 提示 invalid_client 但账号明明是对的现象:POST /oauth/token返回error="invalid_client",client_id 和 client_secret 核对过没有拼写错误。原因:数据库里的client_secret存的是 BCrypt 加密后的密文,而 Spring 的ClientDetailsService默认用明文比较。项目的AuthorizationServerConfig里如果漏了passwordEncoder配置,就会一直报这个错。解决:检查AuthorizationServerConfigurerAdapter.configure(ClientDetailsServiceConfigurer)里有没有指定passwordEncoder。没有就补上:clients.jdbc(dataSource).passwordEncoder(new BCryptPasswordEncoder());5.2 回调地址对不上导致无限重定向现象:用户登录成功之后,浏览器一直在授权页和回调页之间来回跳,最终页面报redirect_uri_mismatch。原因:oauth_client_details.web_server_redirect_uri里配置的是http://localhost:8081/callback,客户端实际回调写的是http://127.0.0.1:8081/callback,Spring 对 URI 做精确匹配,localhost 与 127.0.0.1 视作不同地址。解决:把两端配置改成完全一致,最好都用域名而不是 IP;同时检查客户端模块setRedirectUri有没有显式设置,未设置时框架会用当前请求的完整地址作为回调,动态拼接的地址很容易触发 mismatch。5.3 JWT 签名验证报 SignatureException现象:资源服务器访问受保护接口时抛异常,日志里出现JWT signature does not match locally computed signature。原因:JwtAccessTokenConverter在服务端用的签名密钥是demo-sign-key-change-me,资源服务器配置成了别的值;或者有一方经过配置中心覆盖了密钥。解决:把签名密钥抽成环境变量或配置项,服务端与资源服务器引用同一个 key。我一般还会在测试环境固定一个校验逻辑:解码 JWT 头,看alg字段,若为HS256,确认两端共享密钥一致即可。5.4 刷新令牌返回 null 或提示 unauthorized现象:access_token 过期后自动刷新,OAuth2RestTemplate抛异常,提示 refresh token 无效。原因:JWT 模式不保存服务端会话,刷新令牌的续期逻辑依赖 refresh_token 里的client_id和user_name信息。如果客户端注册时的authorized_grant_types缺少refresh_token,换取令牌时服务端根本不会返回刷新令牌。解决:在oauth_client_details表中为authorized_grant_types补齐authorization_code,refresh_token;并确认refresh_token_validity设置得比 access_token 的有效期长。5.5 scope 不一致导致 403 而不是 401现象:令牌明明有效,请求资源接口时返回 403,日志提示Access is denied。原因:客户端申请的 scope 是read,但资源接口的方法上标注的是#oauth2.hasAnyScope('write'),令牌里的 scope 列表与接口要求不一致,此时 Spring 返回 403。解决:查看 JWT payload 里的scope字段,确认客户端申请时的 scope。不要只看代码里标注了什么,以令牌实际携带的信息为准。5.6 Spring Security 版本兼容问题现象:更换到 Spring Boot 2.7.18 后,@EnableAuthorizationServer报错找不到注解。原因:Spring 官方从 Boot 2.7 开始将 spring-security-oauth2 标记为 deprecated 并迁移到 spring-authorization-server,旧的AuthorizationServerConfigurerAdapter在新版本中不再自动加载。解决:有两条路线。留在 Boot 2.7.x 并把依赖锁定在2.5.2.RELEASE,完全不用改动代码;或者升级到 Boot 3.x + spring-authorization-server,那相当于重写授权服务端配置。学习项目用前者足够,生产若想升级则要做好大量适配工作。6. 二次开发思路:多客户端注册与自定义 JWT 载荷6.1 多客户端注册与动态密钥加载项目默认只注册了一个demo-client,真实环境至少要区分内部后台和移动端。多客户端的关键是在oauth_client_details表插入新记录,给每个客户端配独立的 scope 和回调地址。配置已经走 jdbc 动态读取,所以不需要改代码。若想让不同客户端使用不同签名密钥,需要自定义JwtAccessTokenConverter的SigningKeyResolver,让它根据 JWT 头里的kid字段查询对应密钥。这是把单密钥升级为多密钥的常用手法,也便于后续做密钥轮换。6.2 自定义声明到资源侧的落地令牌里的username、authorities、tenant_id在资源侧不应该是黑匣子。我一般做法是写一个ThreadLocal工具类,在接口拦截器里解析 payload 存入ThreadLocal,接口处理完成后再 remove,避免线程池复用造成数据串台。做完这一步,Controller 就可以直接读tenant_id做数据隔离,同一套资源服务同时服务多个租户不再是难事。6.3 改造后的验证路线扩展多客户端或自定义声明后,按四步验证:重启服务端,确认/oauth/authorize能正常打开;用新 client_id 走一遍授权码流程,看回调是否成功;随后请求/oauth/token,解码新的 JWT 检查新增声明;最后请求资源接口,确认资源侧能读到新增字段。四步走完,绝大多数改动在十分钟内就能回归。从那以后我做任何 OAuth 改造,都会强制把“解码 JWT 看声明”作为第一步,而不是盲改配置,能省下不少排查时间,希望帮到你。本文还有配套的精品资源,点击获取