1、为什么需要登录鉴权?
几乎所有业务系统都离不开登录功能。每个用户都有自己独立的数据,当用户发起请求时,服务器需要识别请求方是谁。
比如,我们提交账号密码访问登录接口:
POST /login
username = zhangsan
password = 123456
登录成功之后,后续访问查询接口:
GET /user/info
GET /order/list
服务器怎么知道,这个请求来自张三?这就是身份认证(Authentication)要解决的问题。
同时我们不希望用户每次访问接口,都重复输入账号密码。登录成功后,需要一套机制,让后续请求携带凭证,告诉服务器:我已经登录,我是张三。 Cookie、Session、JWT,就是用来实现会话跟踪的技术。
2、基础会话技术:Cookie、Session、JWT
2.1 Cookie与特点
Cookie是客户端会话跟踪技术,数据主要存储在客户端的浏览器中,由 HTTP 协议支持。
例如,用户第一次登录成功后,服务器可以通过响应头设置Cookie:
Set-Cookie: username = Tom
浏览器收到后自动保存 Cookie;之后发起符合规则的请求,浏览器会自动带上 Cookie:
Cookie: username=Tom
Cookie核心特点:
- 服务器可以通过响应将 Cookie 发送给浏览器
- 浏览器会自动保存 Cookie
- 后续符合条件的请求中,浏览器会自动携带 Cookie
Cookie 和浏览器绑定很深,传统 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 Session
Session 是一种服务器端的会话管理机制。
它的核心思想是:
用户登录之后,服务器在自己的服务器端保存用户的登录状态,并给客户端一个 Session ID 作为凭证。
举个例子:
服务器维护映射: abc123→{userId:1011}
通过 Cookie 返回: Set-Cookie: JSESSIONID=abc123
浏览器保存 Cookie,后续请求自动带上 JSESSIONID。服务器拿到这个 id,去服务端查询,识别用户身份。
这里需要注意:
Session 本身保存在服务器端,而 Cookie 通常只是负责保存和携带 Session ID。
因此 Cookie 和 Session 并不是一回事。
2.4 JWT
JWT(JSON 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
|--- 其他形式的Token
2.6 Session和JWT的核心区别
Session 和 JWT 最大的区别之一,就是用户登录状态主要保存在哪里。
Session
登录
↓
服务器创建 Session
↓
服务器保存:
SessionId → 用户信息
↓
客户端保存 SessionId
↓
后续请求携带 SessionId
↓
服务器查询 Session
因此 Session 通常被称为有状态认证。
例如 Session 保存在服务器内存中:
服务器重启
↓
内存中的 Session 数据丢失
↓
客户端虽然还有 JSESSIONID
↓
服务器却找不到对应 Session
↓
需要重新登录
当然,实际项目中 Session 也可以存储在 Redis、数据库等外部存储中,这种情况下应用服务器重启并不一定会导致 Session 丢失
JWT
JWT 的主要信息直接包含在 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
- 后续请求:后续请求通过拦截器校验 JWT
3.1 登录:校验账号密码,签发 JWT
- 前端提交账号密码到登录接口(登录接口加入白名单)
- Controller接收参数,Service 查询数据库,对密码进行 MD5 摘要后比对
- 校验成功,生成JWT令牌,令牌payload存放员工id
- 将token返回给前端;前端把token保存到浏览器localStorage
登陆成功,后端将员工ID放入JWT的Payload中
Map<String, 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
↓
Service
3.4 ThreadLocal 作用,以及必须清除的原因
拦截器已经拿到了员工ID,为什么不直接传给Controller呢?
因为后续业务调用会经过很多层,如果每一层都传id,代码会变得很麻烦,所以Interceptor就在当前线程中保存用户ID,把 id 存入 ThreadLocal,当前线程内任意位置都可以直接获取
Long empId = BaseContext.getCurrentId();重点坑点:请求结束必须清理 ThreadLocal Tomcat 使用线程池,请求处理完成线程不会销毁,放回线程池复用。
如果不清空 ThreadLocal:A 用户的 id 残留在线程里,B 用户请求复用这条线程,业务代码读到 A 的 id,造成数据错乱。
afterCompletion方法中执行清理:BaseContext.removeCurrentId();
3.5 完整请求链路
登录流程
后续请求流程
4. 方案二: Spring Security + JWT + Redis + SSO
苍穹外卖的方案简单直观,但只解决「识别用户是谁」的问题。 当项目权限体系变复杂,我们可以使用 Spring Security 安全框架,它把认证、授权整套逻辑标准化。我们只需要提供用户和权限数据,框架负责后续安全流程。
引入依赖:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security</artifactId> </dependency>
4.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 & AdminDetails
Spring 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 登录设计:JWT+Redis,舍弃 Session
Spring Security 默认情况下,使用 Session 保存 SecurityContext。
我们这里采用无状态方案,关闭 Session。
在 Spring Security 中配置:
// 配置Spring Security创建Session的策略:STATELESS=从不使用Session,NEVER=不主动创建Session http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);STATELESS:不创建、不使用 Session,每一次请求,都需要重新校验 JWT,构建认证信息。
当前项目不依赖 Session 保存用户登录状态。
登录成功之后,服务端做两件事:
- 生成 JWT,JWT 载荷只存放 id、username;
- 把完整登录信息(id、账号启用状态、登录 IP、User-Agent、权限列表)存入 Redis,key 就是 JWT,过期时间和 JWT 保持一致。
为什么不把权限直接放进 JWT?
JWT一旦签发,载荷内容固定,无法动态修改。
如果管理员后台禁用用户、修改权限,已经签发的 JWT 里面的权限不会自动更新。
把权限、账号状态放在 Redis,可以动态管控;JWT 只用来做身份凭证。
4.6 JwtAuthorizationFilter 过滤器整体作用
不再使用 SpringMVC 拦截器,而是使用 Security 体系下的自定义 Filter。 过滤器会加入 Security 过滤器链,每次请求执行这套逻辑:
1.从 Authorization 请求头拿到 Bearer 后的 JWT;
后续请求一般会把 JWT 放在:Authorization
请求头中。
例如:Authorization: Bearer xxxxxxxxxxxxx
2.查询 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 的授权机制使用的是:GrantedAuthority
6.组装 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 Security |
|---|---|---|
| JWT | 使用 | 使用 |
| 请求拦截 | Interceptor | Filter |
| 当前用户 | ThreadLocal | SecurityContext |
| 身份对象 | employeeId | Principal / Authentication |
| 用户查询 | Service自己完成 | UserDetailsService |
| 密码认证 | 自己处理 | AuthenticationManager |
| 权限体系 | 相对简单 | GrantedAuthority |
| 方法权限 | 自己判断 |
|
| Session | 不依赖 | STATELESS |
| Redis | 当前方案中不依赖 | 保存登录信息、权限等 |
| JWT退出 | 可自行设计 | 白名单/黑名单均可 |
| JWT盗用检测 | 需要自行实现 | 项目中已经实现 |
| 框架完整度 | 较轻量 | 更完整 |
| 学习难度 | 较低 | 较高 |
核心差异总结
抽象来看,这两套方案要解决的本质问题是一样的:登录时给用户发凭证,之后每次请求,后端靠这个凭证识别用户是谁。
- 手写方案:
足够轻量、灵活,鉴权逻辑全都自己写,自由度很高。适合业务简单、权限不复杂的小项目。
缺点也很明显:权限校验、安全防护、登出使凭证失效这些功能,全都要自己手动实现。项目一旦变得复杂,后续维护会越来越麻烦。 - Spring Security:它自带一套标准的安全模型,把认证、用户管理、权限控制都统一封装好了,开发的时候不用从零手写整套鉴权逻辑。
6. 学习总结
通过这两个项目,我对 Java 后端登录鉴权的理解也从最开始的:
“登录之后生成一个 JWT,后面请求带上 JWT 就行了。”
逐渐有了完整的鉴权链路:
用户登录 ↓ 身份认证 ↓ 生成JWT ↓ 客户端保存JWT ↓ 后续请求携带JWT ↓ 服务器验证JWT ↓ 建立当前请求的认证信息 ↓ 判断用户权限 ↓ 执行业务
苍穹外卖帮我看懂了鉴权底层原理
而 Spring Security 项目进一步将:
Authentication
UserDetails
UserDetailsService
AuthenticationManager
SecurityContext
Principal
GrantedAuthority
这些认证授权概念统一起来,再结合:
JWT + Redis + Filter
实现更加完整的登录鉴权体系。
写在最后
写完这篇博客,其实自己感觉还有很多不足的地方,相较于网上那些成熟、完善的技术文章,这篇更像是我个人学习路上的一份复盘笔记,思路不算特别完美,部分知识点也还有很多精进的空间。
其实一开始我接触登录鉴权是学校的实训项目,里面就用的Security框架,那会真搞不懂,只能跟着抄代码,顶多留了一点印象。后来跟着某马敲完外卖,学会了手写 JWT 这套相对简单的鉴权实现。然后感觉这两种底层有点像啊,于是回头对照实训项目里的代码,反复对比才算一点点搞懂:Security 到底该怎么用、为什么要结合redis、为什么要用安全框架哈哈。
所以这篇文章更多是对现阶段学习成果的整理,难免存在理解不到位或表述不准确的地方,特别欢迎大家指正、一起交流。