☰
从苍穹外卖到 Spring Security:Java 后端登录鉴权的实现与原理
2026/10/12 3:49:35 网站建设 项目流程

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核心特点:

  1. 服务器可以通过响应将 Cookie 发送给浏览器
  2. 浏览器会自动保存 Cookie
  3. 后续符合条件的请求中,浏览器会自动携带 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 → 用户

这样的登录映射状态,只要:

  1. JWT未被篡改
  2. JWT没有过期
  3. 服务器仍然使用对应的SecretKey

服务器就可以验证这个 JWT。

需要注意,JWT本身支持无状态验证,但这并不意味着使用JWT的项目一定完全无状态。如果项目需要主动推出登录、动态修改权限、禁用账号或检测JWT盗用,也可以结合Redis保存必要的服务端状态。后面的Spring Security方案采用的就是这种设计。

3. 方案一:JWT + SpringMVC Interceptor

苍穹外卖并没有直接使用Spring Security,而是自己实现了一套比较简单的登录鉴权机制:

JWT + Spring MVC Interceptor + ThreadLocal

整个过程可以分成两个阶段:

  • 登陆阶段:登录校验账号密码生成 JWT
  • 后续请求:后续请求通过拦截器校验 JWT

3.1 登录:校验账号密码,签发 JWT

  1. 前端提交账号密码到登录接口(登录接口加入白名单)
  2. Controller接收参数,Service 查询数据库,对密码进行 MD5 摘要后比对
  3. 校验成功,生成JWT令牌,令牌payload存放员工id
  4. 将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 保存用户登录状态。

登录成功之后,服务端做两件事:

  1. 生成 JWT,JWT 载荷只存放 id、username;
  2. 把完整登录信息(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 失效。

提供了两种解决方案:

  1. 方案一:白名单
    Redis 保存所有有效的 JWT。
    登录成功写入 Redis;退出登录,直接删除 Redis 里这条 JWT。
    后续请求查询 Redis,如果查不到,认证失败。
  2. 方案二:黑名单
    正常登录不用保存 JWT。
    用户退出登录时,将当前 JWT 存入黑名单。
    后续请求校验 JWT 前,先判断是否在黑名单内,存在就拒绝访问。

白名单 vs 黑名单对比

白名单黑名单
Redis保存什么有效JWT已失效JWT
登录时保存JWT通常不需要保存
退出时删除JWT加入黑名单
Redis存在JWT有效无效
Redis不存在JWT无效继续验证

白名单方案的特点是:

服务器明确维护当前有效的登录状态。

黑名单方案的特点是:

正常 JWT 不需要全部保存,只有需要强制失效的 JWT 才进入黑名单。


4.9 拓展:SSO 单点登录

单点登录的基本实现思想:

  1. 当客户端提交登录请求时,服务器端在验证登录成功后,将生成此用户对应的JWT数据,并响应到客户端
  2. 客户端在后续的访问中,将自行携带JWT数据发起请求,通常,JWT数据会放在请求头的Authorization属性中
  3. 在服务器端的任何服务都可以解析JWT数据,从而创建对应的Authentication对象,然后,将Authentication对象存入到SecurityContext中

单点登录(SSO)的核心是建立统一的身份认证机制:用户在统一认证中心完成登录后,访问其他关联系统时,可以复用已有的认证状态,而不必在每个系统中重复登录。

JWT可以作为跨系统传递身份凭证的一种方式,Redis则可以用于管理登录状态、权限和主动失效等信息。不过,真正实现SSO还需要设计统一认证中心、各子系统的信任关系以及凭证的验证方式,不能仅凭多个系统都能解析JWT,就认为已经实现了完整的单点登录。


4.10 方案二完整请求流程

到这里,整个 Spring Security + JWT + Redis 的流程就可以串起来了。

登录

后续请求


5. 两大方案横向对比

对比项苍穹外卖Spring Security
JWT使用使用
请求拦截InterceptorFilter
当前用户ThreadLocalSecurityContext
身份对象employeeIdPrincipal / Authentication
用户查询Service自己完成UserDetailsService
密码认证自己处理AuthenticationManager
权限体系相对简单GrantedAuthority
方法权限自己判断

@PreAuthorize

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、为什么要用安全框架哈哈。

所以这篇文章更多是对现阶段学习成果的整理,难免存在理解不到位或表述不准确的地方,特别欢迎大家指正、一起交流。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询