首页 / 资讯中心 / 文章详情

Java从零实现OAuth 2.0授权服务器与JWT令牌

Java从零实现OAuth 2.0授权服务器与JWT令牌 ★ FEATURED ARTICLE
简介这是一份面向Java开发者的OAuth 2.0授权框架完整示例覆盖服务端与客户端实现整合JWT令牌与Apache OLTU库并给出授权端点、令牌端点、刷新令牌端点、资源端点等四大核心模块的代码。压缩包共221个文件以176个Java源文件为主辅以22个XML配置如Maven依赖与端点设置、properties属性文件、JSON数据以及license/notice等元信息整体仅355KB轻量便于快速阅读。项目包含授权服务器配置、资源服务器配置、认证提供者、JWT访问令牌生成器等核心类以及针对OAuth请求、令牌生成、资源访问等环节的测试代码可直接运行验证。目前已吸引2184人学习下载适合刚接触OAuth2.0或需要在Java项目中集成第三方授权的开发者既能对照学习协议细节也能作为项目脚手架快速复用无论是课程设计还是生产环境集成均有直接借鉴价值。1. 为什么说 OAuth 2.0 JWT 是接入第三方登录前最值得先自己写一遍的东西接入 OAuth 2.0 最折磨人的不是看懂那套授权码流程而是手册翻了一堆代码里却不知道从哪下手。真正动手写过一遍服务端和客户端才明白令牌是怎么发的、回调地址为什么不能乱填、JWT 为什么能省掉一次数据库查询。这个项目就是干这件事的用 Java 从零实现一个 OAuth 2.0 授权服务器再写一个客户端连它令牌用 JWT 承载最后落到一个受保护的资源接口上。它不是某个第三方登录 SDK 的封装也不是拿 Spring Authorization Server 套一层壳而是把授权码、令牌、刷新令牌、JWT 签发与校验这一整条链路都拆开摆在工程里适合想搞懂协议、又想把代码跑起来的人。我自己接手过的几个项目都踩过这套协议的坑回调地址不一致、scope 配置错位、令牌过期判断失效。与其看一堆概念图不如直接拉这份代码下来跑一遍再对着源码改一版自己的。下面我会把授权链路、核心实现、运行步骤和常见坑挨个讲清楚。2. 架构与授权链路授权码模式怎么从请求串成一条线2.1 三个角色的边界划分OAuth 2.0 里最基础的是授权码模式整套流程会涉及四个角色资源所有者用户、客户端你的应用、授权服务器、资源服务器。这个项目里授权服务器和资源服务器是两个独立的 Spring Boot 应用共享同一套 JWT 密钥配置但业务上完全分开。我见过不少初学者把授权服务器和资源服务器混在一个工程里结果客户端直接拿着密码去换用户信息等于把协议最核心的“授权”环节跳过了。这个项目把两个服务拆开跑就是要强迫你理解令牌的流转授权服务器只管签令牌资源服务器只认令牌两边不直接通信。客户端和授权服务器之间的交互靠浏览器重定向完成授权码和令牌都通过 HTTP 传递不存在共享 session 这种玄学设计。以下是你需要知道的端口和应用对应关系应用端口职责授权服务器9000登录页、授权确认页、授权码签发、令牌端点客户端8080跳转授权、接收回调、携带令牌访问资源资源服务器9001提供受保护接口校验 JWT2.2 授权码流程的完整时序把流程拆成一步步的请求比看任何架构图都直观。假设用户要登录一个叫“某应用”的客户端用户访问客户端的/oauth/authorize接口客户端检测到未登录生成一个随机 state 值存在 session 里然后 302 跳转到授权服务器的/oauth/authorize?response_typecodeclient_id...redirect_uri...state...。授权服务器发现用户未登录弹出登录页用户名密码验证通过后询问用户是否授权该客户端。用户点击同意授权服务器生成一个一次性授权码 code重定向回客户端的回调地址http://localhost:8080/callback?codexxxstatexxx。客户端校验 state 与 session 中保存的值一致后拿 code、client_id、client_secret 直接调用授权服务器的/oauth/token端点这个请求是服务端到服务端不再经过浏览器。授权服务器校验 code 和客户端凭据一次性消费掉这个 code返回 access_tokenJWT 格式、refresh_token、expires_in。客户端拿着 access_token 请求资源服务器的/api/user资源服务器解析 JWT 校验签名放行后返回用户信息。这段时序里最容易被忽视的是第二步“用户确认授权”。很多内网系统为了实现免登录就直接跳过这一步但对外部应用来说授权确认是防止客户端越权获取用户数据的唯一屏障。这个项目是保留了的目的是让你看到完整链路。2.3 为什么 JWT 放在资源服务器而不是授权服务器签发项目名的重点是 JWT但 JWT 并不负责“授权”它只是令牌的一种承载格式。授权服务器负责签发 JWT资源服务器负责校验 JWT。常见的误解是“JWT 是加密的所以安全”。实际上 JWT 默认只做 Base64 编码加签名payload 部分任何人拿到都能解码看内容签名只保证内容没被篡改。所以敏感信息不要直接塞进 JWT 的 payload。我一般会这样设计JWT 里只放 user_id、client_id、scope、过期时间用户昵称头像之类的信息放在资源服务器查库。这样每个接口拿到 user_id 后自己加载数据JWT 不膨胀也避免用户资料修改后令牌不更新的问题。这个项目也是这个思路后面拆代码时可以对照着看。3. 核心模块拆解授权服务器、令牌端点与 JWT 过滤器这一章我挑项目里最关键的四个模块讲授权服务器的配置、令牌端点的签发逻辑、客户端发起授权请求的处理、资源服务器校验 JWT 的过滤器。读完你基本能明白一个请求从进入到返回的完整链路上代码是怎么走的。3.1 授权服务器客户端注册与授权码下发授权服务器的核心配置在AuthorizationServerConfig中它负责管理客户端信息和授权码的生成。看一下我简化后的配置代码Configuration EnableAuthorizationServer public class AuthorizationServerConfig extends AuthorizationServerConfigurerAdapter { Autowired private AuthenticationManager authenticationManager; Autowired private DataSource dataSource; Override public void configure(ClientDetailsServiceConfigurer clients) throws Exception { clients.jdbc(dataSource) .withClient(web-client) .secret(passwordEncoder().encode(web-secret)) .redirectUris(http://localhost:8080/callback) .authorizedGrantTypes(authorization_code, refresh_token) .scopes(read, write) .accessTokenValiditySeconds(3600) .refreshTokenValiditySeconds(604800); } Override public void configure(AuthorizationServerEndpointsConfigurer endpoints) { endpoints.authenticationManager(authenticationManager) .tokenStore(tokenStore()) .accessTokenConverter(accessTokenConverter()); } }这段配置说明几个关键点jdbc(dataSource)表示客户端信息存在数据库里而不是写死在内存中。生产环境多用这种方式因为可以动态新增客户端而无需重启服务。redirectUris是白名单授权码只能回跳到这个地址。很多安全问题就出在回调地址没做好白名单校验。authorizedGrantTypes里同时配了authorization_code和refresh_token。前者是主流程后者是用来续期 access_token 的不配的话刷新令牌的接口就不可用。accessTokenValiditySeconds控制 access_token 的存活时间这个项目演示设成了 3600 秒实际业务里按安全等级调整金融类建议 15 分钟以内。3.2 令牌端点JWT 的生成与刷新授权码换令牌的逻辑在TokenEndpoint里Spring Security OAuth 2.0 的旧版框架帮我们处理了大部分 HTTP 细节但 JWT 的生成需要自定义TokenEnhancer。下面这段代码展示了 JWT 里额外放了什么信息Component public class CustomTokenEnhancer implements TokenEnhancer { Override public OAuth2AccessToken enhance(OAuth2AccessToken accessToken, OAuth2Authentication authentication) { MapString, Object additionalInfo new HashMap(); additionalInfo.put(user_id, getUserId(authentication)); additionalInfo.put(issuer, auth-server); ((DefaultOAuth2AccessToken) accessToken).setAdditionalInformation(additionalInfo); return accessToken; } private String getUserId(OAuth2Authentication authentication) { // 从认证信息里取用户id实际项目从数据库或session中获取 return authentication.getName(); } }这段代码的作用是往 JWT 的 payload 里追加 user_id 和 issuer 两个自定义字段。注意我没有把用户角色、手机号这些敏感数据放进去因为 JWT 是 base64 编码客户端完全可以解出来看。TokenEndpoint接收的请求参数是grant_typeauthorization_codecodexxxredirect_urixxx它校验 code 是否有效、redirect_uri 是否与第一步一致、client 凭据是否正确。全部通过后返回 JSON{ access_token: eyJhbGciOiJSUzI1NiIs..., token_type: bearer, expires_in: 3599, refresh_token: def50200... }3.3 客户端发起授权与接收回调客户端的核心类是OAuthClientController这里面藏着最容易踩坑的 state 校验逻辑。直接看代码Controller public class OAuthClientController { GetMapping(/oauth/authorize) public String redirectToAuthServer(HttpSession session) { String state UUID.randomUUID().toString(); session.setAttribute(oauth_state, state); String authUrl http://localhost:9000/oauth/authorize ?response_typecode client_idweb-client redirect_uri URLEncoder.encode(callbackUrl, UTF-8) state state; return redirect: authUrl; } GetMapping(/callback) public String callback(RequestParam(code) String code, RequestParam(state) String state, HttpSession session, Model model) { String savedState (String) session.getAttribute(oauth_state); if (savedState null || !savedState.equals(state)) { throw new IllegalStateException(state 校验失败可能遭遇 CSRF 攻击); } // 拿着 code 去换 token然后把 token 存到 session OAuth2AccessToken token tokenService.exchangeCodeForToken(code); session.setAttribute(access_token, token.getValue()); model.addAttribute(token, token.getValue()); return success; } }这段代码有两个关键设计state 必须在发起授权时生成、存 session回调时比对。如果对不上直接拒绝。这是对抗 CSRF 的标准做法很多老项目直接忽略 state 参数导致攻击者可以诱导用户完成授权后把 code 转发给攻击者。code 换令牌是服务端行为client_secret 不能出现在前端代码里。这个项目的tokenService.exchangeCodeForToken用 RestTemplate 调用授权服务器的/oauth/tokenclient_secret 放在服务端配置里页面代码完全接触不到。3.4 资源服务器JWT 过滤器校验令牌资源服务器不需要知道授权服务器的任何状态它只做一件事解析 JWT验证签名检查过期时间。我把校验逻辑写在JwtAuthenticationFilter里Component public class JwtAuthenticationFilter extends OncePerRequestFilter { Value(${jwt.publicKey}) private String publicKey; Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws IOException, ServletException { String header request.getHeader(Authorization); if (header ! null header.startsWith(Bearer )) { String token header.substring(7); try { JwtClaims claims JwtUtil.parseToken(token, publicKey); request.setAttribute(user_id, claims.get(user_id)); request.setAttribute(scope, claims.get(scope)); } catch (Exception e) { response.setStatus(HttpStatus.UNAUTHORIZED.value()); response.getWriter().write(invalid token); return; } } filterChain.doFilter(request, response); } }这里是整个项目最值得看的一段逻辑资源服务器的过滤器拿到Authorization: Bearer xxx请求头后用公钥验签 JWT验签通过就把 user_id 塞进 request attribute后续 Controller 直接取。整个过程没有查库、没有会话、没有远程调用这就是 JWT 带来的性能优势。但要注意签名算法依赖公钥。这个项目的公钥配置在资源服务器的application.yml里实际生产环境公钥应该从授权服务器的 JWKS 端点动态拉取并缓存不然更换密钥时所有资源服务器都要同步更新配置。这里用静态配置是为了演示方便二次开发时可以替换为JwkSetUri配置。4. 本地跑通全流程配置、启动顺序与第一次握手代码拆完下面进入实战环节。照着下面的步骤操作十分钟内能看到整套流程跑通。4.1 启动前需要准备的东西项目是一个 Maven 多模块工程包含三个模块auth-server、client、resource-server。首次运行前需要做两件事确认本机有 JDK 8 和 Maven 3.5。初始化数据库。授权服务器的客户端信息和授权码默认用 JDBC 存储我准备好了一个schema.sql里面包含oauth_client_details和oauth_code两张表的建表语句。执行完mvn clean install后分别启动三个服务。可以按以下命令顺序操作cd auth-server mvn spring-boot:run cd resource-server mvn spring-boot:run cd client mvn spring-boot:run启动顺序其实没有强依赖资源服务器不启动的话最多是最后一步看不到返回数据但建议按授权服务器、资源服务器、客户端的顺序来方便观察日志。授权服务器启动后用浏览器访问http://localhost:9000/oauth/authorize应该能看到登录页和授权确认页。4.2 手动触发一次完整的授权请求打开浏览器访问客户端入口http://localhost:8080/oauth/authorize观察跳转过程第一步浏览器被重定向到授权服务器的登录页。输入预置的用户名密码项目初始化数据里定义了一个测试用户登录成功后页面显示“授权某应用访问你的信息吗”这里点击同意。第二步浏览器跳回http://localhost:8080/callback?codexxxxstateyyyy页面显示 access_token 字符串。把这段 token 复制出来。第三步用 curl 请求资源服务器接口注意请求头格式curl -H Authorization: Bearer eyJhbGciOiJSUzI1NiIs... http://localhost:9001/api/user返回结果里包含 JWT 解析出的 user_id 和 scope。到这里一条完整的授权码链路就走通了。我用这套流程带几个同学跑过80% 的人卡在同一个地方第二步点击同意后页面 404。原因通常是回调地址的端口没对上——客户端回调端口是 8080如果你手动改过application.yml里的端口授权服务器的redirectUris也得同步改两边不一致授权服务器会拒绝回调。这个坑下面单独写一节。4.3 观察关键日志运行过程中有三个日志节点值得盯一下授权服务器端看到Submitted authorization code ... for user说明授权码生成成功Creating access token说明令牌签发完成。客户端这边看到Exchanging code oauth.code.xxx for access token说明回调成功正在换 token。资源服务器端看到请求进来并正常返回 200说明 JWT 验签通过。如果某个环节日志没出现可以先定位是哪一个服务出的问题再按错误信息排查。不要三个服务的日志一起看那种排查方式很累。5. OAuth 2.0 实战避坑回调地址、scope 冲突与时钟偏移这套流程看起来简单但实际运行中会出现不少反直觉的问题。我把高频踩坑记录整理成下面五条每条都是真实发生过的情况。5.1 现象回调时 401授权服务器报Invalid redirect_uri原因我在配置客户端信息时用过http://localhost:8080/callback但本地测试时用http://127.0.0.1:8080/callback访问客户端导致回调地址里返回的也是 127.0.0.1授权服务器比对白名单发现不一致。解决授权服务器配置的 redirect_uri 与客户端实际地址必须完全一致包括域名、端口、路径。localhost 和 127.0.0.1 在字符串对比里是不同的。建议统一使用 localhost并把回调地址配置集中放在配置文件中不要散落多处写死。5.2 现象scope 写了read write却返回insufficient_scope原因授权服务器配置的 scope 是read,write客户端请求时传的 scope 参数却是read write空格分隔。Spring Security OAuth 2.0 的旧版实现按逗号拆分白名单不认空格。解决这是协议层容易忽略的细节。跟着 OAuth 2.0 规范scope 参数应该用空格分隔但项目代码里的ClientDetailsService是按逗号解析的所以要么请求时改逗号要么改服务端自定义解析逻辑。我在策划源码时直接用逗号规避了这个问题但你要知道这里存在规范与实现的偏差。5.3 现象JWT 明明没过期资源服务器却返回 401原因授权服务器签发 JWT 时用expires_in计算过期时间这个值来自accessTokenValiditySeconds。而资源服务器校验时用的是本机系统时钟如果两台服务器的系统时间不一致差距超过容差范围就会出现令牌早于预期过期的情况。解决在资源服务器加一个小的容忍窗口比如校验时把exp减掉 30 秒作为缓冲。生产环境更根本的解法是统一服务器时间同步或者在授权服务器签发时把过期时间稍微拉长一点。但注意拉长过期时间不能解决根本问题只是缓解。5.4 现象刷新令牌时返回invalid_grantrefresh_token明明是刚拿到的原因这个项目演示的刷新令牌是一次性的。用完一次后服务端把旧 refresh_token 作废如果你拿同一个 refresh_token 调两次/oauth/token第二次就会报这个错。另外如果authorizedGrantTypes里没配refresh_token这个错误也会出现。解决客户端在收到新的 refresh_token 后必须丢弃旧的重新持久化。很多客户端只存了 access_token没注意到响应体里刷新令牌已经更新了。我见过不止一个项目因为这个问题用户点两次刷新就退出登录了。5.5 现象资源服务器过滤器异常被全局异常处理器吞掉返回 500原因JwtAuthenticationFilter里手动处理的异常如果重新抛出会被 Spring Boot 的默认异常机制包装成 500。有些项目会加一个RestControllerAdvice想统一处理认证异常但 Filter 阶段的异常根本到不了 ControllerAdvice最后客户端拿到一个 500 而不是 401。解决过滤器里捕获到验签失败时要直接设置 response 状态码并 write 内容如上文代码所示不要向上抛。如果必须用ExceptionHandler统一处理考虑在SecurityConfig的exceptionHandling里配authenticationEntryPoint。最简单可靠的做法就是在 Filter 内自己兜底。6. 二次开发要点从单点登录到多客户端与自定义 scope这套代码的基础版本跑通以后你会想把它接到自己的项目里。我根据自己的使用经验给几个高频改造点从最容易的改到最复杂的。6.1 新增一个客户端现在系统只配置了web-client一个客户端。如果要加一个移动端客户端只需在oauth_client_details表插入一条记录或者用授权服务器的内存配置加一段INSERT INTO oauth_client_details (client_id, client_secret, scope, authorized_grant_types, web_server_redirect_uri, access_token_validity) VALUES (mobile-client, {noop}mobile-secret, read, authorization_code,refresh_token, http://localhost:8081/callback, 3600);注意{noop}前缀是 Spring Security 5 之后要求的前缀表示明文存储密码。生产环境改用{bcrypt}并写入加密后的值。客户端和资源服务器的绑定不在数据库层面在资源服务器的 Controller 里按 scope 判断比如支付类接口要求 scope 里必须有write。6.2 自定义 scope 控制接口权限资源服务器拿到 JWT 里的 scope 之后可以用一个简单的注解控制接口权限。你可以仿照下面这个写法PreAuthorize(hasAuthority(SCOPE_read)) GetMapping(/api/user) public UserInfo getUser(RequestAttribute(user_id) String userId) { return userService.findByUserId(userId); }这个用法让readscope 的用户只能查信息writescope 的用户才能改数据。改造时记得在WebSecurityConfig里开启方法级安全EnableGlobalMethodSecurity(prePostEnabled true)。权限不够时会返回 403与 JWT 校验失败的 401 明确区分。6.3 替换 JWT 的公私钥动态获取把资源服务里的configure改成动态拉取 JWKS 端点是生产化最关键的一步。改造后授权服务器更换密钥对时资源服务器会在验签失败后自动从授权服务器拉取最新的公钥。做法如下Bean public JwtDecoder jwtDecoder() { return NimbusJwtDecoder.withJwkSetUri(http://localhost:9000/.well-known/jwks.json) .build(); }去掉自己写的公钥静态配置启用 Spring Security OAuth 2.0 的默认OAuth2ResourceServerConfigurer资源服务器的代码量还能减掉一大半。但这个项目保留了手工过滤器目的是让你看清 JWT 的解析过程。做二次开发时建议先保留手工实现跑通业务流程再慢慢切换回框架默认组件。6.4 一点工程习惯把授权服务器、客户端、资源服务器拆成三个 Maven 模块之后三份application.yml中相同的配置项比如 JWT 密钥、token 过期时间建议提到父 POM 的 properties 里或者用配置中心管理。我早期改造时直接在三个模块里手改相同的密钥结果只改了授权服务器资源服务器验签全部失败排查了半小时才意识到是密钥没同步。从那以后我每次牵涉到安全相关配置都会强制走一遍“改配置 → 搜全局引用 → 三端日志比对”的流程确认没有遗漏才进入联调。这套 OAuth 2.0 JWT 项目本身就是个很好的练习场改坏了大不了重来希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站