
1、为什么需要登录鉴权几乎所有业务系统都离不开登录功能。每个用户都有自己独立的数据当用户发起请求时服务器需要识别请求方是谁。比如我们提交账号密码访问登录接口POST /loginusername zhangsanpassword 123456登录成功之后后续访问查询接口GET /user/infoGET /order/list服务器怎么知道这个请求来自张三这就是身份认证Authentication要解决的问题。同时我们不希望用户每次访问接口都重复输入账号密码。登录成功后需要一套机制让后续请求携带凭证告诉服务器我已经登录我是张三。 Cookie、Session、JWT就是用来实现会话跟踪的技术。2、基础会话技术Cookie、Session、JWT2.1 Cookie与特点Cookie是客户端会话跟踪技术数据主要存储在客户端的浏览器中由 HTTP 协议支持。例如用户第一次登录成功后服务器可以通过响应头设置CookieSet-Cookie: username Tom浏览器收到后自动保存 Cookie之后发起符合规则的请求浏览器会自动带上 CookieCookie: usernameTomCookie核心特点服务器可以通过响应将 Cookie 发送给浏览器浏览器会自动保存 Cookie后续符合条件的请求中浏览器会自动携带 CookieCookie 和浏览器绑定很深传统 Web 项目大量使用。它本身并非不安全安全性取决于配置是否开启 HTTPS、HttpOnly、Secure、SameSite以及存放的数据内容。移动端 App 也可以使用 Cookie但很多项目会选择 Token 认证。2.2 跨域与同源策略前后端分离项目前后端部署地址不一样就会遇到跨域。例如前端 http://192.168.150.200 后端 http://192.168.150.100:8080浏览器打开前端页面后由前端向后端发送请求http://192.168.150.200/login.html ↓ http://192.168.150.100:8080/login由于两个地址的Origin源不同因此浏览器会受到同源策略的限制这就是跨域问题。判断同源主要看三个部分协议主机域名或 IP端口只要其中任意一个不同就属于不同源。需要注意跨域主要是浏览器的同源策略带来的限制并不是说服务器之间无法直接进行通信。2.3 SessionSession 是一种服务器端的会话管理机制。它的核心思想是用户登录之后服务器在自己的服务器端保存用户的登录状态并给客户端一个 Session ID 作为凭证。举个例子服务器维护映射: abc123→{userId:1011}通过 Cookie 返回: Set-Cookie: JSESSIONIDabc123浏览器保存 Cookie后续请求自动带上 JSESSIONID。服务器拿到这个 id去服务端查询识别用户身份。这里需要注意Session 本身保存在服务器端而 Cookie 通常只是负责保存和携带 Session ID。因此 Cookie 和 Session 并不是一回事。2.4 JWTJWTJSON Web Token是一种用于在通信双方之间传递信息的 Token 格式。它的特点是自包含可以在 Token 中携带一些与用户身份相关的信息并通过数字签名保证 Token 的完整性。一个 JWT 通常由三部分组成Header.Payload.Signature组成第一部分Header(头 记录令牌类型、签名算法等。 例如{alg:HS256,type:JWT}第二部分Payload(有效载荷携带一些自定义信息、默认信息等。 例如{id:1,username:Tom}第三部分Signature(签名防止Token被篡改。将header、payload并加入指定秘钥通过指定签名算法计算而来。JWT 的Payload 只是Base64 编码可解码查看不是加密签名仅校验有没有被篡改不能加密内容。因此不应该在其中存放密码、银行卡号等敏感信息。2.5 Token和JWT的关系Token 可以理解成一个比较宽泛的概念表示用于证明身份或权限的“令牌”。JWT 是 Token 的一种具体实现形式。例如Token||--- JWT|--- Session ID|--- 其他形式的Token2.6 Session和JWT的核心区别Session 和 JWT 最大的区别之一就是用户登录状态主要保存在哪里。Session登录↓服务器创建 Session↓服务器保存SessionId → 用户信息↓客户端保存 SessionId↓后续请求携带 SessionId↓服务器查询 Session因此 Session 通常被称为有状态认证。例如 Session 保存在服务器内存中服务器重启↓内存中的 Session 数据丢失↓客户端虽然还有 JSESSIONID↓服务器却找不到对应 Session↓需要重新登录当然实际项目中 Session 也可以存储在 Redis、数据库等外部存储中这种情况下应用服务器重启并不一定会导致 Session 丢失JWTJWT 的主要信息直接包含在 Token 中登录↓服务器生成 JWT↓返回 Token↓客户端保存 JWT↓后续请求携带 JWT↓服务器验证 Signature、过期时间等↓从 Payload 中获取用户信息因此 JWT 通常被称为无状态认证。服务器一般不需要像 Session 一样维护JWT → 用户这样的登录映射状态只要JWT未被篡改JWT没有过期服务器仍然使用对应的SecretKey服务器就可以验证这个 JWT。需要注意JWT本身支持无状态验证但这并不意味着使用JWT的项目一定完全无状态。如果项目需要主动推出登录、动态修改权限、禁用账号或检测JWT盗用也可以结合Redis保存必要的服务端状态。后面的Spring Security方案采用的就是这种设计。3. 方案一JWT SpringMVC Interceptor苍穹外卖并没有直接使用Spring Security而是自己实现了一套比较简单的登录鉴权机制JWT Spring MVC Interceptor ThreadLocal整个过程可以分成两个阶段登陆阶段登录校验账号密码生成 JWT后续请求后续请求通过拦截器校验 JWT3.1 登录校验账号密码签发 JWT前端提交账号密码到登录接口登录接口加入白名单Controller接收参数Service 查询数据库对密码进行 MD5 摘要后比对校验成功生成JWT令牌令牌payload存放员工id将token返回给前端前端把token保存到浏览器localStorage登陆成功后端将员工ID放入JWT的Payload中MapString, Object claims new HashMap(); claims.put(JwtClaimsConstant.EMP_ID, employee.getId()); String token JwtUtil.createJWT( jwtProperties.getAdminSecretKey(), jwtProperties.getAdminTtl(), claims );3.2 登录接口为什么需要配置白名单这里有一个很容易理解错的地方。登录请求本身也会经过 Interceptor。但是登录的时候用户还没有 JWT。如果 Interceptor 对所有请求都要求 JWT那么就会出现登录↓Interceptor↓要求 JWT↓没有 JWT↓拒绝请求这样用户就永远无法登录所以登录接口需要加入白名单/login↓Interceptor↓发现是白名单接口↓直接放行↓Controller也就是说白名单不是绕过登录而是为了让用户能够完成登录。3.3 请求拦截拦截器校验 JWT用户登录成功以后在访问其他受保护的接口前端会携带之前获得的Token。请求进入服务器后拦截器从请求头拿到 token解析校验 JWT拿到员工 id存入 ThreadLocal放行请求HTTP请求↓DispatcherServlet↓JwtTokenInterceptor↓获取 Token↓解析并校验 JWT↓获取 employeeId↓保存到 ThreadLocal↓Controller↓Service3.4 ThreadLocal 作用以及必须清除的原因拦截器已经拿到了员工ID,为什么不直接传给Controller呢因为后续业务调用会经过很多层如果每一层都传id代码会变得很麻烦所以Interceptor就在当前线程中保存用户ID把 id 存入 ThreadLocal当前线程内任意位置都可以直接获取Long empId BaseContext.getCurrentId();重点坑点请求结束必须清理 ThreadLocal Tomcat 使用线程池请求处理完成线程不会销毁放回线程池复用。如果不清空 ThreadLocalA 用户的 id 残留在线程里B 用户请求复用这条线程业务代码读到 A 的 id造成数据错乱。afterCompletion方法中执行清理BaseContext.removeCurrentId();3.5 完整请求链路登录流程后续请求流程4. 方案二 Spring Security JWT Redis SSO苍穹外卖的方案简单直观但只解决「识别用户是谁」的问题。 当项目权限体系变复杂我们可以使用 Spring Security 安全框架它把认证、授权整套逻辑标准化。我们只需要提供用户和权限数据框架负责后续安全流程。引入依赖dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-security/artifactId /dependency4.1 基础概念认证 和 授权在学习 Spring Security 之前首先需要区分两个概念认证Authentication你是谁校验账号密码确认用户身份。授权Authorization你能干什么认证完成后判断这个用户有没有访问接口的权限。Spring Security 同时负责认证和授权这两个过程4.2 Security 登录阶段的原生流程登录阶段的原生流程用户提交账号密码封装成UsernamePasswordAuthenticationToken交给AuthenticationManager完成认证。认证过程中 Security 会自动调用我们自定义的UserDetailsService。UsernamePasswordAuthenticationToken authenticationToken new UsernamePasswordAuthenticationToken( username, password ); Authentication authentication authenticationManager.authenticate(authenticationToken);这里的UsernamePasswordAuthenticationToken可以理解成把用户提交的用户名和密码交给 Spring Security 进行认证。4.3 用户信息封装UserDetailsService AdminDetailsSpring Security 并不知道我们的用户数据到底存在哪里。在使用Spring Security框架时可以自定义组件类实现UserDetailsService接口则Spring Security就会基于此类的对象来处理认证。所以我们需要实现UserDetailsService接口并重写loadUserByUsername(String username)方法。这个方法的职责根据用户名查数据库把数据库用户信息封装成 Security 能识别的UserDeatails对象返回。我们自定义AdminDetails继承 Security 自带的 User额外扩展业务字段 id等。相当于业务用户模型和 Security 框架之间的桥梁同时包含框架需要的用户名、密码、启用状态、权限。Spring Security 并不关心数据库具体长什么样我们只需要把数据库中的用户信息转换成 Spring Security 能够识别的 UserDetails即可。AdminDetails是项目中的用户信息与 Spring Security 之间的一个桥梁。4.4 核心对象Authentication / Principal / SecurityContext认证成功之后Spring Security 会产生一个Authentication对象代表当前用户认证完成一个Authentication中比较重要的信息包括Principal当前用户是谁Credentials认证凭证Authenticated是否已经认证成功GrantedAuthorities当前用户拥有的权限而SecurityContext则负责保存当前的Authentication。所以可以简单理解成SecurityContext 存放当前请求的认证信息后续代码随时可以拿到。4.5 登录设计JWTRedis舍弃 SessionSpring Security 默认情况下使用 Session 保存 SecurityContext。我们这里采用无状态方案关闭 Session。在 Spring Security 中配置// 配置Spring Security创建Session的策略STATELESS从不使用SessionNEVER不主动创建Session http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);STATELESS不创建、不使用 Session每一次请求都需要重新校验 JWT构建认证信息。当前项目不依赖 Session 保存用户登录状态。登录成功之后服务端做两件事生成 JWTJWT 载荷只存放 id、username把完整登录信息id、账号启用状态、登录 IP、User-Agent、权限列表存入 Rediskey 就是 JWT过期时间和 JWT 保持一致。为什么不把权限直接放进 JWTJWT一旦签发载荷内容固定无法动态修改。如果管理员后台禁用用户、修改权限已经签发的 JWT 里面的权限不会自动更新。把权限、账号状态放在 Redis可以动态管控JWT 只用来做身份凭证。4.6 JwtAuthorizationFilter 过滤器整体作用不再使用 SpringMVC 拦截器而是使用 Security 体系下的自定义 Filter。 过滤器会加入 Security 过滤器链每次请求执行这套逻辑1.从 Authorization 请求头拿到 Bearer 后的 JWT后续请求一般会把 JWT 放在Authorization请求头中。例如Authorization: Bearer xxxxxxxxxxxxx2.查询 Redis校验这个 JWT 是否存在如果能查到说明这个 JWT 目前处于有效登录状态。如果 Redis 中已经不存在这个 JWT则无法继续完成后续认证。3.安全校验对比登录 IP、User-Agent 做盗用检测校验账号是否被禁用项目在 Redis 中保存登录信息的时候还保存了remoteAddr登录IP userAgent登陆设备/浏览器信息enable(禁用状态)后续请求到达 Filter 后会重新获取当前请求的remoteAddr userAgent然后和 Redis 中保存的登录信息进行比较如果 IP 和 User-Agent 都发生变化则认为这个 JWT 存在被盗用的可能。Filter 在认证过程中还会检查账号状态。如果enable 0说明当前账号已被禁用。需要注意这种方式是一种风险检测机制并不能百分之百证明 JWT 一定被盗。4.校验 JWT 签名和有效期解析拿到 id、username拿到 JWT 中的id username 构建 LoginPrincipal对象这里可以把LoginPrincipal理解为当前请求对应的用户身份信息。5.读取 Redis 中的权限列表转换成 Security 需要的 GrantedAuthority为什么一定要转换因为 Spring Security 的授权机制使用的是GrantedAuthority6.组装 Authentication 对象存入 SecurityContext现在我们已经有了LoginPrincipal 权限接下来就需要使用UsernamePasswordAuthenticationToken重新创建Authentication这里特别需要注意这里是后续请求产生的 Authentication并不是登录时的Authentication 被保存下来继续使用。7.后续 Controller、权限注解就可以获取用户身份和权限。Controller使用AuthenticationPrincipal获取当前登录用户4.7 接口授权PreAuthorize 方法级权限控制开启 Spring Security 的方法级权限控制EnableGlobalMethodSecurity(prePostEnabled true)在接口上直接声明访问该接口所需要的权限例如新增管理员PreAuthorize(hasAuthority(/ams/admin/add-new))当认证信息存入 SecurityContext 之后PreAuthorize自动读取 Authentication 里面的权限判断用户是否允许访问接口。Controller 中可以使用AuthenticationPrincipal直接拿到当前用户信息替代 ThreadLocal。GetMapping(/list) public JsonResult list(AuthenticationPrincipal AdminDetails user) { log.debug(当前用户id{}, user.getId()); return ...; }4.8 JWT 主动失效白名单与黑名单两种登出方案这里的JWT白名单与前面登录接口的白名单不是同一个概念。前者用于管理当前有效的登陆凭证后者用于哪些接口可以不经过登录凭证。问题原生 JWT 无法主动失效JWT 有一个特点只要 JWT 没有过期并且签名验证通过理论上就仍然可以使用。例如JWT有效期2小时用户登录 10 分钟后点击退出登录如果服务器什么都不做那么这个 JWT 仍然可能继续使用剩余的 1 小时 50 分钟。所以JWT 的退出登录不能只依赖前端删除 Token。服务器也需要让这个 JWT 失效。提供了两种解决方案方案一白名单Redis 保存所有有效的 JWT。登录成功写入 Redis退出登录直接删除 Redis 里这条 JWT。后续请求查询 Redis如果查不到认证失败。方案二黑名单正常登录不用保存 JWT。用户退出登录时将当前 JWT 存入黑名单。后续请求校验 JWT 前先判断是否在黑名单内存在就拒绝访问。白名单 vs 黑名单对比白名单黑名单Redis保存什么有效JWT已失效JWT登录时保存JWT通常不需要保存退出时删除JWT加入黑名单Redis存在JWT有效无效Redis不存在JWT无效继续验证白名单方案的特点是服务器明确维护当前有效的登录状态。黑名单方案的特点是正常 JWT 不需要全部保存只有需要强制失效的 JWT 才进入黑名单。4.9 拓展SSO 单点登录单点登录的基本实现思想当客户端提交登录请求时服务器端在验证登录成功后将生成此用户对应的JWT数据并响应到客户端客户端在后续的访问中将自行携带JWT数据发起请求通常JWT数据会放在请求头的Authorization属性中在服务器端的任何服务都可以解析JWT数据从而创建对应的Authentication对象然后将Authentication对象存入到SecurityContext中单点登录SSO的核心是建立统一的身份认证机制用户在统一认证中心完成登录后访问其他关联系统时可以复用已有的认证状态而不必在每个系统中重复登录。JWT可以作为跨系统传递身份凭证的一种方式Redis则可以用于管理登录状态、权限和主动失效等信息。不过真正实现SSO还需要设计统一认证中心、各子系统的信任关系以及凭证的验证方式不能仅凭多个系统都能解析JWT就认为已经实现了完整的单点登录。4.10 方案二完整请求流程到这里整个 Spring Security JWT Redis 的流程就可以串起来了。登录后续请求5. 两大方案横向对比对比项苍穹外卖Spring SecurityJWT使用使用请求拦截InterceptorFilter当前用户ThreadLocalSecurityContext身份对象employeeIdPrincipal / Authentication用户查询Service自己完成UserDetailsService密码认证自己处理AuthenticationManager权限体系相对简单GrantedAuthority方法权限自己判断PreAuthorizeSession不依赖STATELESSRedis当前方案中不依赖保存登录信息、权限等JWT退出可自行设计白名单/黑名单均可JWT盗用检测需要自行实现项目中已经实现框架完整度较轻量更完整学习难度较低较高核心差异总结抽象来看这两套方案要解决的本质问题是一样的登录时给用户发凭证之后每次请求后端靠这个凭证识别用户是谁。手写方案足够轻量、灵活鉴权逻辑全都自己写自由度很高。适合业务简单、权限不复杂的小项目。缺点也很明显权限校验、安全防护、登出使凭证失效这些功能全都要自己手动实现。项目一旦变得复杂后续维护会越来越麻烦。Spring Security它自带一套标准的安全模型把认证、用户管理、权限控制都统一封装好了开发的时候不用从零手写整套鉴权逻辑。6. 学习总结通过这两个项目我对 Java 后端登录鉴权的理解也从最开始的“登录之后生成一个 JWT后面请求带上 JWT 就行了。”逐渐有了完整的鉴权链路用户登录 ↓ 身份认证 ↓ 生成JWT ↓ 客户端保存JWT ↓ 后续请求携带JWT ↓ 服务器验证JWT ↓ 建立当前请求的认证信息 ↓ 判断用户权限 ↓ 执行业务苍穹外卖帮我看懂了鉴权底层原理而 Spring Security 项目进一步将AuthenticationUserDetailsUserDetailsServiceAuthenticationManagerSecurityContextPrincipalGrantedAuthority这些认证授权概念统一起来再结合JWT Redis Filter实现更加完整的登录鉴权体系。写在最后写完这篇博客其实自己感觉还有很多不足的地方相较于网上那些成熟、完善的技术文章这篇更像是我个人学习路上的一份复盘笔记思路不算特别完美部分知识点也还有很多精进的空间。其实一开始我接触登录鉴权是学校的实训项目里面就用的Security框架那会真搞不懂只能跟着抄代码顶多留了一点印象。后来跟着某马敲完外卖学会了手写 JWT 这套相对简单的鉴权实现。然后感觉这两种底层有点像啊于是回头对照实训项目里的代码反复对比才算一点点搞懂Security 到底该怎么用、为什么要结合redis、为什么要用安全框架哈哈。所以这篇文章更多是对现阶段学习成果的整理难免存在理解不到位或表述不准确的地方特别欢迎大家指正、一起交流。