
简介这套源码面向需要在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 groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version2.7.18/version /parent dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.security.oauth/groupId artifactIdspring-security-oauth2/artifactId version2.5.2.RELEASE/version /dependency dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-api/artifactId version0.11.5/version /dependency dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-impl/artifactId version0.11.5/version scoperuntime/scope /dependency dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-jackson/artifactId version0.11.5/version scoperuntime/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()); } }先看ClientDetailsServiceConfigurerclients.jdbc(dataSource)告诉框架从oauth_client_details表加载客户端密码用 BCrypt 加密。数据库里的client_secret存的是 BCrypt 密文不是明文这是很多第一次跑通 OAuth 的人也会踩的坑——拿明文 secret 去请求令牌一直报invalid_client。再看AuthorizationServerEndpointsConfigurerauthenticationManager必须传授权码模式下用户登录后的认证结果需要它做二次确认userDetailsService则是刷新令牌时重新加载用户信息的入口。tokenStore用的是 JwtTokenStore它本身不存数据只把 JWT 字符串解析回OAuth2AccessToken对象真正的状态都在令牌里所以服务端不需要统一保存会话。tokenEnhancer挂的是自定义增强器后面会具体展开。这里要提醒的是如果你同时配置了accessTokenConverter和自定义TokenEnhancer两者的调用顺序由 Spring 组装链决定增强器先运行再交给转换器签名反过来写会拿不到增强器塞进去的附加参数。3.2 授权码的一生从 authorize 到 callback授权码模式的完整调用链是客户端把用户引导到/oauth/authorize?response_typecodeclient_iddemo-clientredirect_urihttp://localhost:8081/callbackscoperead服务端先弹登录页登录成功后生成授权码并回跳到回调地址。授权码生成过程在 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等标准声明业务上往往不够用所以源码里带了一个CustomTokenEnhancerpublic class CustomTokenEnhancer implements TokenEnhancer { Override public OAuth2AccessToken enhance(OAuth2AccessToken accessToken, OAuth2Authentication authentication) { final MapString, 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 SupplierString tokenSupplier; public BearerTokenInterceptor(SupplierString 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返回errorinvalid_clientclient_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/callbackSpring 对 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 看声明”作为第一步而不是盲改配置能省下不少排查时间希望帮到你。本文还有配套的精品资源点击获取