一般系统的登录鉴权是如何实现的

9023 字
45 分钟
一般系统的登录鉴权是如何实现的

一、什么是登录鉴权,为什么需要它?#

1、什么是登录鉴权?#

简单来说,登录鉴权就是解决一个问题:

“你是谁?你有没有权限访问这个资源?”

在实际系统中,几乎所有的功能都不是”谁都能用”的。比如:

  • 用户必须先登录,才能看到自己的订单
  • 管理员才能看到后台的用户管理页面
  • 普通用户不能删除别人的数据

这就需要系统在用户访问资源时,先验证用户的身份,再判断用户有没有权限做这个操作。

2、登录鉴权包含两个核心概念#

概念说明回答的问题
认证(Authentication)验证”你是谁”你真的是张三吗?
授权(Authorization)验证”你能做什么”张三有没有权限访问这个接口?

这两个概念在实际开发中经常被一起提到,但它们做的事情是不同的:

  • 认证:通常发生在登录阶段,验证用户名密码是否正确
  • 授权:通常发生在用户访问某个接口或页面时,判断该用户是否有权限

3、为什么需要了解登录鉴权?#

虽然基本上不会让你从头实现登录鉴权,但它是几乎所有业务系统都有的功能,作为程序员还是要有一些基本认识。而且,入职后你大概率会碰到以下场景:

  • 自测接口的时候,需要知道如何去传认证登录的参数
  • 需要做接口鉴权的时候,要明白一般的鉴权逻辑,这样你才能更好的理解你们公司的鉴权方式
  • 需要放行某些接口(不需要登录就能访问)的时候,要知道怎么配置,比如登录接口本身、注册接口、验证码接口、公开页面等
  • 写业务代码时经常需要获取当前登录用户(比如记录”谁创建的""谁修改的”),要知道怎么从后端拿,而不是让前端传
  • 和同事沟通登录鉴权相关需求的时候,要具备这方面基本的认知能力

二、登录鉴权的整体流程#

不管系统用的是哪种技术方案,大多数登录鉴权的整体流程都可以概括为下面几个步骤:

登录鉴权整体流程
登录鉴权整体流程

用一句话概括:

登录时验证身份,生成凭证;后续请求携带凭证,后端校验凭证和权限。

三、常见的登录认证方式#

1、前后端不分离项目#

前后端不分离的老项目的登录认证一般都是使用的 Session + Cookie 的方式。

Session+Cookie工作流程
Session+Cookie工作流程

1.1 工作流程#

2
2

1.2 简单理解#

你可以把 Session + Cookie 理解成这样:

你去银行办业务
你拿着身份证去柜台验证身份(提交用户名密码)
验证通过,柜台为你建立一份专属档案,并交给你一张号码牌
(登录成功,创建 Session,存入用户信息,通过 Set-Cookie 返回 JSESSIONID)
你拿着号码牌去办业务(浏览器后续请求自动带上 Cookie)
柜台看到号码牌,查到你的档案,办理业务(后端根据 Session ID 找到用户信息)

1.3 关键点#

关键点说明
Session 存在哪服务端(内存、Redis、数据库等)
Session ID 怎么传给前端通过 HTTP 响应头 Set-Cookie
Session 创建时机理论上首次调用 request.getSession() 就会触发自动创建,但在企业实战中,为了防内存溢出与 Session 固定攻击,拦截器绝不盲目创建,正式 Session 必须在用户登录成功并销毁旧会话后强制重建
前端怎么带 Session ID浏览器每次请求自动携带 Cookie
Session 过期服务端设置超时时间,超时后 Session 失效
用户退出登录服务端销毁 Session,前端清除 Cookie

1.4 优点#

  • 实现简单,浏览器自动管理 Cookie
  • 服务端可以主动让 Session 失效(用户退出登录、修改密码等场景)
  • 状态可控,服务端可以随时查看和管理所有在线用户

1.5 缺点#

  • 分布式问题:Session 默认存在应用服务器内存中,如果是集群部署(多台服务器),用户第一次请求到 A 服务器创建了 Session,第二次请求被分发到 B 服务器,B 服务器没有这个 Session,就会认为用户未登录
  • 跨域问题:Cookie 在跨域场景下处理比较麻烦
  • 移动端不友好:APP、小程序等非浏览器环境,没有自动管理 Cookie 的能力

1.6 分布式 Session 的常见解决方案(了解)#

方案说明
Session 共享(Redis)把 Session 存到 Redis 中,所有服务器都从 Redis 读写 Session
IP Hash(Nginx)同一 IP 的请求始终路由到同一台服务器,但不适合服务器宕机切换的场景
粘性 Session负载均衡器将同一用户的请求始终分配到同一台服务器

其中,把 Session 存到 Redis 是企业里最常见的方式。


2、前后端分离的项目#

在前后端分离的项目中,我们通常采用 Token 机制 来实现登录认证,而 JWT 则是目前最常用的 Token 格式。

Token自测示例
Token自测示例

2.1 什么是 Token?#

Token,翻译过来就是”令牌”。

它就是一个字符串,服务端在用户登录成功后生成,返回给前端,前端后续每次请求都带上这个 Token,服务端通过校验 Token 来判断用户身份。

2.2 什么是 JWT?#

JWT(JSON Web Token)是目前最常用的 Token 格式。

一个 JWT 由三部分组成:

Header(头部).Payload(载荷).Signature(签名)

例如:

eyJhbGciOiJIUzI1NiJ9.eyJyb2xlIjoiQURNSU4iLCJ1c2VySWQiOjEsInZlcnNpb24iOjAsInVzZXJuYW1lIjoiYWRtaW4iLCJpYXQiOjE3ODA2MjkzNjAsImV4cCI6MTc4MTIzNDE2MH0.YMHDAIskTfOcOl4tjWFUUqwcBsvKA4O_xuD2wijezXQ

这三部分的作用:

部分说明
Header描述 Token 类型和签名算法,例如 {“alg”: “HS256”, “typ”: “JWT”}
Payload存放用户信息和自定义数据,例如用户 ID、用户名、过期时间等
Signature对前两部分的签名,用于验证 Token 是否被篡改

注意

JWT 的 Payload 部分只是经过 Base64 编码,不是加密。所以,不要在 JWT 中存放密码、手机号等敏感信息。

2.3 工作流程#

5
5

密钥(Secret Key)的作用说明

密钥是 JWT 安全性的核心,它只保存在服务端,不会泄露给前端。它的作用有两个:

  • 生成 Token 时:后端用密钥对 Header 和 Payload 进行签名(计算出一个哈希值),拼接到 JWT 的第三部分(Signature)
  • 校验 Token 时:后端用同一个密钥重新计算签名,如果和 JWT 中携带的签名一致,说明 Token 没有被篡改

如果密钥泄露了,任何人都可以伪造 JWT,所以密钥的安全保管非常重要。

2.4 简单理解#

你去景区玩
售票处验证你的身份证(验证用户名密码)
验证通过,给你一张"门票"(生成 JWT)
门票上写了你的名字、有效期(JWT 的 Payload)
门票上有景区的防伪印章(JWT 的 Signature)
你每次进景点,工作人员看门票(后端校验 JWT)
门票是真的且没过期 → 放行
门票是假的或已过期 → 拒绝进入

2.5 Token 和 Session 的核心区别(了解)#

对比项Session + CookieToken(JWT)
用户信息存储位置服务端(内存/Redis)Token 本身(客户端携带)
服务端是否需要存储会话信息需要JWT 本身可以做到无状态,但实际项目中为了实现踢人下线、Token 续期、多端登录控制等能力,经常配合 Redis 使用
分布式支持需要额外做 Session 共享天然支持,因为 Token 自带用户信息
跨域支持较差较好
主动失效容易(删掉服务端 Session 即可)较难(JWT 签发后,在过期之前始终有效)
移动端支持不太方便非常适合

2.6 JWT 的优点(了解)#

  • 无状态:JWT 本身可以做到无状态,服务端不需要存储 Session 信息,天然支持分布式
  • 跨域友好:适合前后端分离、多端(Web、APP、小程序)场景
  • 性能好:理论上 JWT 中的信息可以直接使用,不必依赖服务端会话存储;但实际项目中通常仍会结合 Redis 缓存用户信息

2.7 JWT 的缺点(了解)#

  • 无法主动失效:JWT 签发后,在过期之前始终有效。用户修改密码、被踢下线等场景不好处理
  • Payload 不可太大:JWT 每次请求都要携带,如果 Payload 里塞太多数据,会增加请求体积
  • Token 续期问题:Token 过期了怎么处理?是用 Refresh Token 续期,还是让用户重新登录?

2.8 JWT 无法主动失效的常见解决方案#

虽然 JWT 本身无法主动失效,但在实际项目中通常有以下几种处理方式:

方案说明
缩短 Token 有效期把 Token 有效期设短一些(比如 30 分钟),配合 Refresh Token 使用
黑名单机制用户退出登录或修改密码时,把对应 Token 加入黑名单(通常存到 Redis),校验时先查黑名单
Refresh Token登录时同时返回一个短期 Token 和一个长期 Refresh Token,Token 过期后用 Refresh Token 换取新的 Token

四、如何获取当前登录用户#

用户登录之后,我在业务代码里怎么知道”当前操作的人是谁”?

这个问题在实际开发中非常常见。和登录认证一样,获取登录用户信息一般也有现成的代码,不需要从头写。你需要记住两点:

  1. 不要让前端传用户 ID(除非你的组长明确告诉你这样做)
  2. 获取登录用户是通过 Session 或 Token 实现的,项目中一般已有封装好的工具类,找到直接用就行(最好封装成工具类,后面会频繁用到)。找不到的话,先让 AI 帮你搜,AI 也找不到就问同事:“我们项目里有没有获取当前登录用户的方法?“确认没有再自己写

1、为什么需要获取当前登录用户?#

举几个你入职后一定会遇到的场景:

场景说明
数据新增新增一条记录时,要记录这条数据是谁创建的(create_by 字段)
数据修改修改数据时,要记录是谁改的(update_by 字段)
操作日志记录”张三在 2024-01-01 删除了某条数据”
数据权限查询时只返回当前用户自己的数据,比如”我的订单”

这些场景都有一个共同点:后端需要知道当前请求是谁发的,而不是让前端来告诉后端。

2、为什么不能让前端传用户 ID?#

有些新人可能会想:前端直接把用户 ID 传过来不就行了?

// ❌ 错误做法:让前端传 userId
@PostMapping("/add")
public Result add(@RequestParam Long userId, @RequestBody Order order) {
order.setCreateBy(userId); // 前端传过来的 userId
orderService.save(order);
return Result.success();
}

这存在严重的安全问题:

如果有人用 Postman 之类的工具直接调用接口,把 userId 改成别人的,就能以别人的身份创建数据。这等于说,谁都能冒充任何人操作。

所以,当前用户的身份必须从后端的认证信息中获取,不能依赖前端传递。

正确的做法是:

// ✅ 正确做法:从后端的认证上下文中获取当前用户
@PostMapping("/add")
public Result add(@RequestBody Order order) {
Long userId = SecurityUtils.getUserId(); // 从后端获取,而非前端传入
order.setCreateBy(userId);
orderService.save(order);
return Result.success();
}

3、真实工作中一般获取登录用户的代码#

以下均是真实工作中项目代码截图。从这些代码可以看出,这种获取登录用户的逻辑都是封装到工具类里面的。

获取登录用户示例1
获取登录用户示例1

获取登录用户示例2
获取登录用户示例2

获取登录用户示例3
获取登录用户示例3

4、一般都是怎么实现的?#

不同登录认证方式,获取用户的实现也有所不同。但一般思路知道即可,真实工作中需要你写的可能性极低,即使让你写,也可以让 AI 直接帮你写。

一般思路如下:

拦截请求 → 解析凭证 → 存储当前用户 → 后续随时获取用户信息

1)、拦截请求

请求到达后端后,会先经过全局拦截器(Filter / Interceptor)。它会检查请求中是否携带了 Token、Session ID 等登录凭证。

2)、解析用户信息

  • 如果是 JWT Token: 后端会解析 Token,拿到 userId 等用户信息,必要时再去 Redis 查询完整用户数据。
  • 如果是 Session: 后端会根据 Session ID,从 Tomcat 或 Redis 中取出对应的用户对象。

3)、存入当前线程(ThreadLocal)

拿到用户信息后,框架会把当前用户存入 ThreadLocal(例如 Spring Security 的 SecurityContext)。

这样后续业务代码中,就不需要层层传递用户参数了。在 Controller、Service 里直接调用类似:

SecurityUtils.getUser()

底层其实就是从 ThreadLocal 中获取当前登录用户。

4)、真实项目中常见的获取用户方式(了解)

上面讲的是一般思路,下面是真实项目中的统计,帮你了解实际工作中会遇到哪些方式。

常见方式统计

获取方式使用频率核心代码示例说明
Spring Security Context最常见SecurityContextHolder.getContext().getAuthentication()大部分项目都用这个
自定义 ThreadLocal常见StaffThreadLocal.getStaff()自己封装的工具类
工具类从 Request 解析常见SecurityUserUtil.getCurrentUserId(request)直接从请求头解析Token
框架 API(Sa-Token等)少见StpUtil.getLoginIdAsLong()特定框架提供的API
HttpSession老项目request.getSession().getAttribute(“user”)传统Session方式

实际工作中怎么快速上手?

  1. 找到项目里获取用户的方式:搜索 SecurityUtilsgetLoginUsergetCurrentUser 等关键词
  2. 直接使用:找到后直接用,不用关心底层实现,项目里别人怎么写,你就怎么写
  3. 不要自己造轮子:项目里一般都有现成的工具类,直接调用即可

总结:入职后不需要从头实现获取用户的逻辑,找到项目里现有的方式直接用就行。如果找不到,先问 AI 帮你搜,搜不到再问同事。

五、权限控制(授权)#

工作中,我们写的接口一般都需要设置权限,不会让所有用户都能调用。权限控制这块通常不需要你从 0 到 1 去实现,但你需要先了解常见的权限控制逻辑,这样到了公司才能更好地理解你们项目的权限方案。如果公司采用的方式不是通用模式,直接问同事就行(也可以先让 AI 帮你生成一个权限控制设计方案,再跟同事沟通确认)。如果你连常见的权限控制逻辑都不清楚,可能就很难听懂别人给你讲的东西。

1、RBAC 权限模型#

RBAC(Role-Based Access Control,基于角色的访问控制)是企业中最常见的权限模型。

核心思路

用户 → 角色 → 权限

也就是说:

  • 一个用户可以拥有多个角色
  • 一个角色可以拥有多个权限
  • 一个权限对应一个具体的操作(如:用户列表查询、用户删除等)

数据库设计通常是这几张表

表名说明
user用户表
role角色表
permission权限表
user_role用户-角色关联表
role_permission角色-权限关联表

举例

用户:张三
角色:普通管理员
权限:用户列表查询、用户详情查询、用户编辑
(没有"用户删除"权限)
张三可以查看和编辑用户,但不能删除用户

2、权限控制的常见实现方式#

方式说明示例
接口级权限控制某个接口只有特定角色才能访问@RequiresRoles(“admin”)
按钮级权限控制页面上某个按钮只有特定角色才能看到前端根据权限列表判断是否显示按钮
数据级权限控制用户只能看到自己部门的数据查询时自动拼接部门过滤条件

其中,接口级权限 是后端开发最常接触的。

3、为什么要前后端都做权限控制?#

有些同学可能会想:前端已经根据权限隐藏了按钮,后端还需要再校验吗?

答案是:必须的。

因为前端隐藏按钮只是页面展示层面的控制,用户完全可以通过直接调用接口来绕过前端限制。所以:

前端权限控制是为了用户体验,后端权限控制是为了安全。

六、实际工作中常见的权限控制方式#

下面以国内最常用的后台管理系统脚手架若依(RuoYi)前后端分离版本为例,介绍实际工作中基于 RBAC 模型的权限控制是怎么实现的。很多公司会基于若依进行二次开发,或借鉴其权限方案,入职后你大概率会遇到。

1、权限控制#

若依的权限控制是基于 RBAC 模型 实现的,涉及以下几张核心表:

表名说明
sys_user用户表
sys_role角色表,其中 data_scope 字段控制数据权限等级
sys_menu菜单/权限表,每条菜单对应一个权限标识(如 system:user
sys_user_role用户-角色关联表
sys_role_menu角色-菜单关联表
sys_role_dept角色-部门关联表(数据权限为”自定义”时使用)

接口级权限

若依使用 Spring Security 的 @PreAuthorize 注解来控制接口权限:

按权限字符控制:判断当前用户是否拥有指定权限字符串

@PreAuthorize("@ss.hasPermi('system:user:list')")
@GetMapping("/list")
public TableDataInfo list(SysUser user) {
// ...
}

@ss 是若依自定义的权限服务 PermissionService,注册为 Spring Bean,名称为 sshasPermi 方法会从当前登录用户的权限列表中判断是否包含指定的权限字符串。

// PermissionService.java — 权限判断的核心逻辑
@Service("ss") // 注册为Spring Bean,名称为ss
public class PermissionService {
public boolean hasPermi(String permission) {
LoginUser loginUser = SecurityUtils.getLoginUser();
if (StringUtils.isNull(loginUser) || CollectionUtils.isEmpty(loginUser.getPermissions())) {
return false;
}
// 判断用户的权限列表中是否包含该权限
return hasPermissions(loginUser.getPermissions(), permission);
}
// 支持任意一个权限匹配
public boolean hasAnyPermi(String permissions) {
// 多个权限用逗号分隔,只要有一个匹配就返回true
}
// 判断是否拥有某个角色
public boolean hasRole(String role) {
// 从用户的角色列表中判断
}
// 支持任意一个角色匹配
public boolean hasAnyRoles(String roles) {
// 多个角色用逗号分隔,只要有一个匹配就返回true
}
}

按角色控制:判断当前用户是否拥有指定角色

// 判断是否拥有 admin 角色
@PreAuthorize("@ss.hasRole('admin')")
@GetMapping("/list")
public TableDataInfo list(SysUser user) {
// ...
}
// 判断是否拥有任意一个角色(admin 或 common)
@PreAuthorize("@ss.hasAnyRoles('admin,common')")
@GetMapping("/list")
public TableDataInfo list(SysUser user) {
// ...
}

按钮级权限

前端使用自定义指令 v-hasPermi 来控制按钮是否显示:

<!-- 只有拥有 system:user:add 权限的用户才能看到"新增"按钮 -->
<el-button v-hasPermi="['system:user:add']">新增</el-button>
<el-button v-hasPermi="['system:user:edit']">修改</el-button>
<el-button v-hasPermi="['system:user:remove']">删除</el-button>
<!-- 需要同时拥有多个权限中的任意一个 -->
<el-dropdown v-hasPermi="['system:user:resetPwd', 'system:user:edit']">
<el-button>更多操作</el-button>
</el-dropdown>

v-hasPermi 指令的判断逻辑:

hasPermi.js
export default {
inserted(el, binding) {
const { value } = binding // 按钮上写的权限标识,如 ['system:user:add']
const permissions = store.getters.permissions // 用户拥有的权限列表(从 /getInfo 接口获取)
const hasPermissions = permissions.some(permission => {
return "*:*:*" === permission // 超级管理员有所有权限
|| value.includes(permission) // 或者用户权限列表中包含这个标识
})
if (!hasPermissions) {
el.parentNode && el.parentNode.removeChild(el) // 没权限就从页面移除按钮
}
}
}

数据权限

前面的接口级权限和按钮级权限解决的是”你能不能调这个接口”的问题,而数据权限解决的是”你能看到哪些数据”的问题

同样是”用户列表”接口,部门经理能看到本部门所有员工的数据,普通员工只能看到自己的数据。

若依的数据权限是通过 AOP + MyBatis 参数注入 实现的,核心思路就是在查询 SQL 中动态拼接 WHERE 条件。

1)数据权限的五个等级

每个角色都有一个 dataScope 字段,对应 sys_role 表的 data_scope 列:

含义生成的 SQL 条件
1所有数据权限不加过滤
2自定义数据权限dept_id IN (SELECT dept_id FROM sys_role_dept WHERE role_id = ?)
3本部门数据权限dept_id = 当前用户部门ID
4本部门及以下数据权限dept_id IN (…本部门及子部门)
5仅本人数据权限user_id = 当前用户ID

2)怎么使用

只需在 Service 方法上加 @DataScope 注解:

@DataScope(deptAlias = "d", userAlias = "u")
public List<SysUser> selectUserList(SysUser user)
{
return userMapper.selectUserList(user);
}

然后在 MyBatis XML 的查询 SQL 末尾加上 ${params.dataScope}

<select id="selectUserList" parameterType="SysUser" resultMap="SysUserResult">
select u.user_id, u.dept_id, u.user_name, d.dept_name
from sys_user u
left join sys_dept d on u.dept_id = d.dept_id
where u.del_flag = '0'
${params.dataScope}
</select>

3)运行原理

AOP 切面(DataScopeAspect)会在方法执行前拦截,获取当前用户的所有角色,根据每个角色的 dataScope 值生成对应的 SQL 过滤条件,注入到方法参数的 params.dataScope 中。这样 MyBatis 执行时就会自动拼接上数据权限的过滤条件。

简单来说:加个 @DataScope 注解 + XML 里写个 ${params.dataScope},数据权限就生效了。

注意:正常业务开发中应谨慎使用 ${},因为它会直接进行字符串拼接,存在 SQL 注入风险(应优先使用 #{})。若依的数据权限实现中,拼接内容由 AOP 切面根据用户角色自动生成,来源可控,因此能够安全使用。不要在其他业务场景中模仿这种写法,除非你能确保拼接内容的安全性。

2、其他权限控制方式#

上面介绍的若依是典型的 RBAC 权限模型,适合角色多、权限细的场景。但有些项目可能并不需要那么精细的权限控制,只需要区分”管理员”和”普通用户”,这种情况下往往直接在代码里加一层判断就行了。

2.1 用户表加一个角色字段#

最简单的做法就是在用户表里加一个字段来标识用户类型:

-- user 表中加一个 role 字段
CREATE TABLE user (
id BIGINT PRIMARY KEY,
username VARCHAR(50),
password VARCHAR(100),
role VARCHAR(20) DEFAULT 'user' -- admin 表示管理员,user 表示普通用户
);

登录后,这个 role 字段会随着用户信息一起存到 Token 或 Session 中。后续请求时,后端直接判断角色即可。

2.2 后端怎么判断权限?#

方式一:在接口里直接判断

最直接的方式,就是在需要权限控制的接口里加一个 if 判断:

@GetMapping("/admin/users")
public Result getUserList() {
User currentUser = SecurityUtils.getCurrentUser();
if (!"admin".equals(currentUser.getRole())) {
return Result.error(403, "无权限访问");
}
// 管理员才能执行的逻辑
return Result.success(userService.list());
}

这种方式简单粗暴,适合接口不多的小项目。但如果需要控制的接口多了,每个接口都写一遍判断就太重复了。

方式二:抽一个公共方法

稍微好一点的做法,是把判断逻辑抽到一个公共方法里:

@GetMapping("/admin/users")
public Result getUserList() {
checkAdmin(); // 公共方法,不是管理员直接抛异常
return Result.success(userService.list());
}
private void checkAdmin() {
User currentUser = SecurityUtils.getCurrentUser();
if (!"admin".equals(currentUser.getRole())) {
throw new BusinessException("无权限访问");
}
}

方式三:用拦截器统一处理

更好的做法是通过拦截器(Filter / Interceptor)统一处理,而不是在每个接口里手动判断:

@Component
public class AdminInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
String uri = request.getRequestURI();
// 只拦截 /admin/** 路径下的接口
if (uri.startsWith("/admin/")) {
User currentUser = SecurityUtils.getCurrentUser();
if (!"admin".equals(currentUser.getRole())) {
response.setStatus(403);
return false;
}
}
return true;
}
}

然后注册拦截器,把 /admin/** 路径的请求都交给它处理:

@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
@Autowired
private AdminInterceptor adminInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(adminInterceptor)
.addPathPatterns("/admin/**");
}
}

这样,所有以 /admin/ 开头的接口,只有管理员才能访问,普通用户会直接被拦截返回 403。不需要在每个接口里写判断,代码更干净。

方式四:用自定义注解

和拦截器类似的思路,也可以通过自定义注解来实现:

// 定义注解
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface RequireAdmin {
}
// 在需要管理员权限的接口上加注解
@RequireAdmin
@GetMapping("/users")
public Result getUserList() {
return Result.success(userService.list());
}

然后通过 AOP 切面来拦截加了 @RequireAdmin 注解的方法:

@Aspect
@Component
public class AdminAspect {
@Before("@annotation(requireAdmin)")
public void checkAdmin(RequireAdmin requireAdmin) {
User currentUser = SecurityUtils.getCurrentUser();
if (!"admin".equals(currentUser.getRole())) {
throw new BusinessException("无权限访问");
}
}
}

这几种方式本质上都是做同一件事:判断当前用户是不是管理员,不是就拒绝访问。区别只是代码组织方式不同。

2.3 前端怎么配合?#

后端控制了接口权限,前端一般也会配合做界面上的控制:

// 根据用户角色控制页面元素的显示
if (user.role === 'admin') {
// 显示管理后台入口、用户管理菜单等
showAdminMenu();
} else {
// 隐藏这些元素
hideAdminMenu();
}

七、登录鉴权常见问题与排查#

1、什么是跨域?跨域和鉴权有什么关系?#

同源策略是浏览器的安全机制:只有当请求的协议、域名、端口三者都相同时,才被认为是”同源”。不满足任一条件就是”跨域”。

例如,前端在 http://localhost:3000,后端在 http://localhost:8080,端口不同,就是跨域。

跨域对鉴权的影响

  • Cookie 方式:默认情况下,跨域请求不会携带 Cookie,需要后端配置 CORS 并设置 Access-Control-Allow-Credentials: true
  • Token 方式:前端手动在请求头中添加 Token,不受 Cookie 限制,但需要后端配置 CORS 允许自定义请求头

后端解决跨域的常见方式

// 方式一:在 Controller 或方法上加注解
@CrossOrigin(origins = "http://localhost:3000")
// 方式二:全局配置
@Configuration
public class CorsConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**")
.allowedOrigins("http://localhost:3000")
.allowedMethods("*")
.allowCredentials(true);
}
}

2、什么是 OAuth 2.0?它和 JWT 什么区别?#

OAuth 2.0 是什么?

OAuth 2.0 是一个授权框架,它解决的核心问题是:

如何让用户在不给第三方应用密码的情况下,授权它访问自己的资源。

最常见的场景就是”第三方登录”,比如:

  • 用微信扫码登录某个网站
  • 用 GitHub 账号登录某个开发工具
  • 用 Google 账号登录某个 App

OAuth 2.0 和 JWT 的区别

这两个东西经常被混淆,但它们根本不在同一层:

对比项OAuth 2.0JWT
本质授权框架(一套流程规范)数据格式(一种 Token 规范)
解决的问题如何让用户安全地授权第三方访问资源Token 怎么生成、怎么校验、怎么防篡改
典型场景微信登录、GitHub 登录前后端分离项目的登录认证

简单来说:OAuth 2.0 定义的是”流程”,JWT 定义的是”格式”。 OAuth 2.0 流程中产生的 Token 可以用 JWT 格式,也可以不用。

作为后端开发,你更常接触的是 JWT。OAuth 2.0 一般在对接第三方登录时才会用到,而且通常已经有现成的 SDK 或框架集成,不需要你自己从头实现。

3、SSO 是什么?#

SSO(Single Sign-On,单点登录)解决的问题是:

用户在多个系统中只需要登录一次,就能访问所有互相信任的系统,不需要重复登录。

举个例子:

你们公司有以下系统:
- OA 系统(oa.company.com)
- HR 系统(hr.company.com)
- 邮件系统(mail.company.com)
没有 SSO 的情况:
- 你要先登录 OA → 输入一次账号密码
- 再登录 HR → 再输入一次账号密码
- 再登录邮件 → 又输入一次账号密码
有了 SSO:
- 你只要登录一次(比如先登录 OA)
- 然后访问 HR、邮件系统,都自动识别你已登录,不需要再输入密码

SSO 的核心思路

用户访问系统 A
系统 A 发现用户没登录,跳转到 SSO 认证中心
用户在认证中心输入账号密码,登录成功
认证中心给用户发一个全局凭证(Ticket / Token),同时记录"这个用户已经登录了"
用户带着凭证回到系统 A,系统 A 拿凭证去认证中心验证,验证通过,登录成功
用户再访问系统 B
系统 B 发现用户没登录,跳转到认证中心
认证中心发现这个用户已经登录过了(不需要再输密码),直接给用户发凭证
用户带着凭证回到系统 B,登录成功

常见实现方式

方式说明典型应用
CAS企业内网最常用的 SSO 方案,基于 Cookie + Ticket公司内部多个系统统一登录
OAuth 2.0适用于第三方应用授权登录微信登录、Google 登录
同域 SSO多个系统在同一个顶级域名下,共享 Cookiea.company.com 和 b.company.com 共享登录状态

入职后你大概率会遇到 SSO,因为只要公司有多个内部系统,基本都会做单点登录。不过 SSO 的实现一般也是现成的,你不需要从零写,了解即可。

4、如何放行某个接口,不需要登录即可访问?#

系统里不是所有接口都需要登录才能访问。有些接口天然就不需要鉴权,如果不放行,功能就会出问题。常见的放行场景如下:

场景典型接口为什么要放行
登录/注册/login、/register、/captcha用户还没登录,不可能有凭证,不放行就永远登不上
公开页面文章详情、商品详情、公告列表这些内容面向所有人,不需要登录就能看
第三方回调微信登录回调、支付回调第三方系统调你的接口,它没有你的登录凭证
健康检查/监控/actuator/health、Spring Boot 健康检查端点运维或监控系统需要定期探测服务是否存活
静态资源图片、CSS、JS 文件前端页面依赖这些资源,不能要求它们也带 Token
分享链接带 Token 的临时分享链接比如”分享给好友查看”,好友没有账号也能访问

想要知道你们项目怎么放行接口的,就全局搜索登录接口,去看它怎么放行的,或者也可以让 AI 告诉你。

常见的放行方式有以下几种:

方式一:在拦截器配置中排除路径

这是最直接的方式,在拦截器注册时指定哪些路径不需要拦截:

@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
@Autowired
private AuthInterceptor authInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(authInterceptor)
.addPathPatterns("/**") // 拦截所有请求
.excludePathPatterns( // 排除以下路径,不做鉴权
"/login",
"/register",
"/captcha",
"/public/**"
);
}
}

方式二:在 Security 配置中放行

如果项目用的是 Spring Security,可以在安全配置类中放行:

@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http.authorizeHttpRequests(auth -> auth
.requestMatchers("/login", "/register", "/captcha", "/public/**").permitAll() // 放行
.anyRequest().authenticated() // 其他请求需要认证
);
return http.build();
}
}

方式三:用自定义注解标记放行接口

有些项目会定义一个 @Anonymous 注解,加在接口上就表示不需要登录:

// 定义注解
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface Anonymous {
}
// 在不需要登录的接口上加注解
@Anonymous
@GetMapping("/public/article/{id}")
public Result getArticle(@PathVariable Long id) {
return Result.success(articleService.getById(id));
}

然后在拦截器中判断:如果接口方法上有 @Anonymous 注解,就直接放行:

@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
// 如果是带有 @Anonymous 注解的接口,直接放行
if (handler instanceof HandlerMethod handlerMethod) {
Anonymous anonymous = handlerMethod.getMethodAnnotation(Anonymous.class);
if (anonymous != null) {
return true;
}
}
// 其他接口正常做鉴权校验
// ...
}

方式三的好处:放行配置直接写在接口上,一目了然,不用跑到配置类里去找。若依框架就是用的这种注解方式(@Anonymous)来放行接口的。

5、常见鉴权问题排查#

实际工作中,以下是最常见的鉴权相关问题及排查思路。

实用排查技巧:参考平台其他接口的传参方式

如果不确定某个接口的认证参数怎么传,可以先登录你们公司的平台,按 F12 打开浏览器开发者工具,切换到 Network(网络)面板,然后在页面上随便操作一下,观察其他接口是怎么传 Token/Cookie 的——请求头叫什么名字、格式是什么(比如 Bearer xxx 还是直接传值)。照着别的接口的传参方式来就行,这是最快的方式,比自己猜或者翻代码都快。

5.1、接口返回 401(未认证)#

说明:请求没有携带有效的登录凭证,或者凭证已失效。

排查步骤:

步骤检查内容
1Token 有没有传?检查请求头中是否包含 Authorization 或 token 字段
2Token 格式对不对?有的项目要求 Bearer xxx,有的只要 Token 值
3Token 是否过期?看看 Token 的过期时间,或者重新登录拿一个新 Token
4请求头名字对不对?不同项目的请求头名称可能不同(Authorization、token、X-Token 等),看项目配置
5是不是跨域导致 Cookie 没带上?Session 方案要检查 Cookie 是否被浏览器拦截
6网关是否丢失了请求头?如果项目用了网关(如 Nginx、Gateway),检查网关是否把认证相关的请求头透传了
7接口是否被拦截器拦截?检查接口是否在放行列表中,如果不在,确认是否需要放行

5.2、接口返回 403(无权限)#

说明:已登录,但当前用户没有权限访问该资源。

排查步骤:

步骤检查内容
1当前用户的角色是否正确?检查用户绑定的角色是否符合预期
2权限字符是否配置?检查接口上的权限注解(如 @RequiresPermissions(“system:user”)),确认权限字符在数据库中是否存在
3菜单权限是否绑定到角色?确认权限字符对应的菜单已经绑定到当前用户的角色上
4数据权限是否限制了数据范围?如果开启了数据权限,可能是 SQL 层面的过滤导致查不到数据,而非 403
5是不是 admin 账号也 403?如果 admin 也 403,说明可能是配置问题;如果只有普通用户 403,大概率是权限没配置到位

5.3、当前用户获取为空#

代码中调用获取当前用户的方法返回 null,一般就这几个原因:

  • 先确认是不是真的登录了(接口有没有返回 401,如果是,先解决登录问题)
  • 如果是在子线程或异步方法(@Async、线程池)中获取用户,ThreadLocal 的值可能没有传递过去,需要在进入异步之前先把用户信息取出来,作为参数传进去:
// 在主线程中先获取当前用户
Long userId = SecurityUtils.getUserId();
// 把 userId 作为参数传给异步方法,不要在异步方法里面再去调 SecurityUtils.getUserId()
@Async
public void doSomething(Long userId) {
// 这里直接用传进来的 userId,不要再去获取当前登录用户
}
  • 确认你调用的获取当前用户的方法是不是项目封装好的那个,不同项目方法名可能不一样

总结:遇到鉴权问题,先确认”有没有登录”(401),再确认”有没有权限”(403),最后确认”能不能拿到用户信息”(null)。按这个顺序排查,基本能覆盖 90% 的场景。

支持与分享

如果这篇文章对你有帮助,欢迎分享给更多人或打赏支持!

打赏
一般系统的登录鉴权是如何实现的
https://study-docs-158.pages.dev/posts/一般系统的登录鉴权是如何实现的/
作者
我的学习小铺
发布于
2026-08-01
许可协议
CC BY-NC-SA 4.0
Profile Image of the Author
我的学习小铺
学而时习之
分类
标签
最新动态
站点统计
文章
41
分类
7
标签
3
总字数
122,278
运行时长
0
最后活动
0 天前
站点信息
构建平台
Local
博客版本
Firefly v6.15.3
文章许可
CC BY-NC-SA 4.0
1
一、什么是登录鉴权,为什么需要它?
1、什么是登录鉴权?
2、登录鉴权包含两个核心概念
3、为什么需要了解登录鉴权?
2
二、登录鉴权的整体流程
3
三、常见的登录认证方式
1、前后端不分离项目
1.1 工作流程
1.2 简单理解
1.3 关键点
1.4 优点
1.5 缺点
1.6 分布式 Session 的常见解决方案(了解)
2、前后端分离的项目
2.1 什么是 Token?
2.2 什么是 JWT?
2.3 工作流程
2.4 简单理解
2.5 Token 和 Session 的核心区别(了解)
2.6 JWT 的优点(了解)
2.7 JWT 的缺点(了解)
2.8 JWT 无法主动失效的常见解决方案
4
四、如何获取当前登录用户
1、为什么需要获取当前登录用户?
2、为什么不能让前端传用户 ID?
3、真实工作中一般获取登录用户的代码
4、一般都是怎么实现的?
5
五、权限控制(授权)
1、RBAC 权限模型
2、权限控制的常见实现方式
3、为什么要前后端都做权限控制?
6
六、实际工作中常见的权限控制方式
1、权限控制
2、其他权限控制方式
2.1 用户表加一个角色字段
2.2 后端怎么判断权限?
2.3 前端怎么配合?
7
七、登录鉴权常见问题与排查
1、什么是跨域?跨域和鉴权有什么关系?
2、什么是 OAuth 2.0?它和 JWT 什么区别?
3、SSO 是什么?
4、如何放行某个接口,不需要登录即可访问?
5、常见鉴权问题排查
5.1、接口返回 401(未认证)
5.2、接口返回 403(无权限)
5.3、当前用户获取为空