芋道 Spring Boot 安全框架 Spring Security 入门
总阅读量:次
摘要: 原创出处 http://www.iocoder.cn/Spring-Boot/Spring-Security/ 「芋道源码」欢迎转载,保留摘要,谢谢!
🙂🙂🙂关注**微信公众号:【芋道源码】**有福利:
- RocketMQ / MyCAT / Sharding-JDBC 所有源码分析文章列表
- RocketMQ / MyCAT / Sharding-JDBC 中文注释源码 GitHub 地址
- 您对于源码的疑问每条留言都将得到认真回复。甚至不知道如何读源码也可以请教噢。
- 新的源码解析文章实时收到通知。每周更新一篇左右。
- 认真的源码交流微信群。
本文在提供完整代码示例,可见 https://github.com/YunaiV/SpringBoot-Labs 的 对应 lab-01-spring-security 目录。
原创不易,给点个 Star 嘿,一起冲鸭!
1. 概述
基本上,在所有的开发的系统中,都必须做认证(authentication)和授权(authorization),以保证系统的安全性。😈 考虑到很多胖友对认证和授权有点分不清楚,艿艿这里引用一个网上有趣的例子:
FROM 《认证 (authentication) 和授权 (authorization) 的区别》
- authentication [ɔ,θɛntɪ'keʃən] 认证
- authorization [,ɔθərɪ'zeʃən] 授权
以打飞机举例子:
- 【认证】你要登机,你需要出示你的 passport 和 ticket,passport 是为了证明你张三确实是你张三,这就是 authentication。
- 【授权】而机票是为了证明你张三确实买了票可以上飞机,这就是 authorization。
以论坛举例子:
- 【认证】你要登录论坛,输入用户名张三,密码 1234,密码正确,证明你张三确实是张三,这就是 authentication。
- 【授权】再一 check 用户张三是个版主,所以有权限加精删别人帖,这就是 authorization 。
所以简单来说:认证解决“你是谁”的问题,授权解决“你能做什么”的问题。另外,在推荐阅读下《认证、授权、鉴权和权限控制》 文章,更加详细明确。
在 Java 生态中,目前有 Spring Security 和 Apache Shiro 两个安全框架,可以完成认证和授权的功能。本文,我们先来学习下 Spring Security 。其官方对自己介绍如下:
FROM 《Spring Security 官网》
Spring Security is a powerful and highly customizable authentication and access-control framework. It is the de-facto standard for securing Spring-based applications.
Spring Security 是一个功能强大且高度可定制的身份验证和访问控制框架。它是用于保护基于 Spring 的应用程序。Spring Security is a framework that focuses on providing both authentication and authorization to Java applications. Like all Spring projects, the real power of Spring Security is found in how easily it can be extended to meet custom requirements
Spring Security 是一个框架,侧重于为 Java 应用程序提供身份验证和授权。与所有 Spring 项目一样,Spring 安全性的真正强大之处,在于它很容易扩展以满足定制需求。
2. 快速入门
示例代码对应仓库:lab-01-springsecurity-demo 。
在本小节中,我们来快速入门下 Spring Security ,实现访问 API 接口时,需要首先进行登录,才能进行访问。
2.1 引入依赖
在 pom.xml
文件中,引入相关依赖。
"1.0" encoding="UTF-8" xml version= |
具体每个依赖的作用,胖友自己认真看下艿艿添加的所有注释噢。
2.2 Application
创建 Application.java
类,配置 @SpringBootApplication
注解即可。代码如下:
// Application.java |
2.3 配置文件
在 application.yml
中,添加 Spring Security 配置,如下:
spring: |
- 在
spring.security
配置项,设置 Spring Security 的配置,对应 SecurityProperties 配置类。 - 默认情况下,Spring Boot UserDetailsServiceAutoConfiguration 自动化配置类,会创建一个内存级别的 InMemoryUserDetailsManager Bean 对象,提供认证的用户信息。
2.4 AdminController
在 cn.iocoder.springboot.lab01.springsecurity.controller
包路径下,创建 AdminController 类,提供管理员 API 接口。代码如下:
// AdminController.java |
- 这里,我们先提供一个
"/admin/demo"
接口,用于测试未登录时,会被拦截到登录界面。
2.5 简单测试
执行 Application#main(String[] args)
方法,运行项目。
项目启动成功后,浏览器访问 http://127.0.0.1:8080/admin/demo 接口。因为未登录,所以被 Spring Security 拦截到登录界面。如下图所示:
因为我们没有自定义登录界面,所以默认会使用 DefaultLoginPageGeneratingFilter 类,生成上述界面。
输入我们在「2.3 配置文件」中配置的「user/user」账号,进行登录。登录完成后,因为 Spring Security 会记录被拦截的访问地址,所以浏览器自动动跳转 http://127.0.0.1:8080/admin/demo 接口。访问结果如下图所示:
3. 进阶使用
示例代码对应仓库:lab-01-springsecurity-demo-role 。
在「2. 快速入门」中,我们很快速的完成了 Spring Security 的入门。本小节,我们将会自定义 Spring Security 的配置,实现权限控制。
考虑到不污染上述的示例,我们新建一个 lab-01-springsecurity-demo-role 项目。
3.1 引入依赖
和 「2.1 引入依赖」 一致,见 pom.xml
文件。
3.2 示例一
在示例一中,我们会看看如何自定义 Spring Security 的配置,实现权限控制。
3.2.1 SecurityConfig
在 cn.iocoder.springboot.lab01.springsecurity.config
包下,创建 SecurityConfig 配置类,继承 WebSecurityConfigurerAdapter 抽象类,实现 Spring Security 在 Web 场景下的自定义配置。代码如下:
// SecurityConfig.java |
- 我们可以通过重写 WebSecurityConfigurerAdapter 的方法,实现自定义的 Spring Security 的配置。
首先,我们重写 #configure(AuthenticationManagerBuilder auth)
方法,实现 AuthenticationManager 认证管理器。代码如下:
// SecurityConfig.java |
<X>
处,调用AuthenticationManagerBuilder#inMemoryAuthentication()
方法,使用内存级别的 InMemoryUserDetailsManager Bean 对象,提供认证的用户信息。- Spring 内置了两种 UserDetailsManager 实现:
- InMemoryUserDetailsManager,和「2. 快速入门」是一样的。
- JdbcUserDetailsManager ,基于 JDBC的 JdbcUserDetailsManager 。
- 实际项目中,我们更多采用调用
AuthenticationManagerBuilder#userDetailsService(userDetailsService)
方法,使用自定义实现的 UserDetailsService 实现类,更加灵活且自由的实现认证的用户信息的读取。
- Spring 内置了两种 UserDetailsManager 实现:
<Y>
处,调用AbstractDaoAuthenticationConfigurer#passwordEncoder(passwordEncoder)
方法,设置 PasswordEncoder 密码编码器。- 在这里,为了方便,我们使用 NoOpPasswordEncoder 。实际上,等于不使用 PasswordEncoder ,不配置的话会报错。
- 生产环境下,推荐使用 BCryptPasswordEncoder 。更多关于 PasswordEncoder 的内容,推荐阅读《该如何设计你的 PasswordEncoder?》文章。
<Z>
处,配置了「admin/admin」和「normal/normal」两个用户,分别对应 ADMIN 和 NORMAL 角色。相比「2. 快速入门」来说,可以配置更多的用户。
然后,我们重写 #configure(HttpSecurity http)
方法,主要配置 URL 的权限控制。代码如下:
// SecurityConfig.java |
-
<X>
处,调用HttpSecurity#authorizeRequests()
方法,开始配置 URL 的权限控制。注意看艿艿配置的四个权限控制的配置。下面,是配置权限控制会使用到的方法:#(String... antPatterns)
方法,配置匹配的 URL 地址,基于 Ant 风格路径表达式 ,可传入多个。- 【常用】
#permitAll()
方法,所有用户可访问。 - 【常用】
#denyAll()
方法,所有用户不可访问。 - 【常用】
#authenticated()
方法,登录用户可访问。 #anonymous()
方法,无需登录,即匿名用户可访问。#rememberMe()
方法,通过 remember me 登录的用户可访问。#fullyAuthenticated()
方法,非 remember me 登录的用户可访问。#hasIpAddress(String ipaddressExpression)
方法,来自指定 IP 表达式的用户可访问。- 【常用】
#hasRole(String role)
方法, 拥有指定角色的用户可访问。 - 【常用】
#hasAnyRole(String... roles)
方法,拥有指定任一角色的用户可访问。 - 【常用】
#hasAuthority(String authority)
方法,拥有指定权限(authority
)的用户可访问。 - 【常用】
#hasAuthority(String... authorities)
方法,拥有指定任一权限(authority
)的用户可访问。 - 【最牛】
#access(String attribute)
方法,当 Spring EL 表达式的执行结果为true
时,可以访问。
-
<Y>
处,调用HttpSecurity#formLogin()
方法,设置 Form 表单登录。- 如果胖友想要自定义登录页面,可以通过
#loginPage(String loginPage)
方法,来进行设置。不过这里我们希望像「2. 快速入门」一样,使用默认的登录界面,所以不进行设置。
- 如果胖友想要自定义登录页面,可以通过
-
<Z>
处,调用HttpSecurity#logout()
方法,配置退出相关。- 如果胖友想要自定义退出页面,可以通过
#logoutUrl(String logoutUrl)
方法,来进行设置。不过这里我们希望像「2. 快速入门」一样,使用默认的退出界面,所以不进行设置。
- 如果胖友想要自定义退出页面,可以通过
3.2.2 TestController
在 cn.iocoder.springboot.lab01.springsecurity.controller
包路径下,创建 TestController 类,提供测试 API 接口。代码如下:
// TestController.java |
- 对于
/test/echo
接口,直接访问,无需登录。 - 对于
/test/home
接口,无法直接访问,需要进行登录。 - 对于
/test/admin
接口,需要登录「admin/admin」用户,因为需要 ADMIN 角色。 - 对于
/test/normal
接口,需要登录「normal/normal」用户,因为需要 NORMAL 角色。
胖友可以按照如上的说明,进行各种测试。例如说,登录「normal/normal」用户后,去访问 /test/admin
接口,会返回 403 界面,无权限~
3.3 示例二
在示例二中,我们会看看如何使用 Spring Security 的注解,实现权限控制。
3.3.1 SecurityConfig
修改 SecurityConfig 配置类,增加 @EnableGlobalMethodSecurity
注解,开启对 Spring Security 注解的方法,进行权限验证。代码如下:
|
3.3.2 DemoController
在 cn.iocoder.springboot.lab01.springsecurity.controller
包路径下,创建 DemoController 类,提供测试 API 接口。代码如下:
// DemoController.java |
-
每个 URL 的权限验证,和「3.2.2 TestController」是一一对应的。
-
@PermitAll
注解,等价于#permitAll()
方法,所有用户可访问。重要!!!因为在「3.2.1 SecurityConfig」中,配置了
.anyRequest().authenticated()
,任何请求,访问的用户都需要经过认证。所以这里@PermitAll
注解实际是不生效的。也就是说,Java Config 配置的权限,和注解配置的权限,两者是叠加的。
-
@PreAuthorize
注解,等价于#access(String attribute)
方法,,当 Spring EL 表达式的执行结果为 true 时,可以访问。
Spring Security 还有其它注解,不过不太常用,可见《区别: @Secured(), @PreAuthorize() 及 @RolesAllowed()》文章。
胖友可以按照如上的说明,进行各种测试。例如说,登录「normal/normal」用户后,去访问 /test/admin
接口,会返回 403 界面,无权限~
4. 整合 Spring Session
参见《芋道 Spring Boot 分布式 Session 入门》文章的「5. 整合 Spring Security」小节。
5. 整合 OAuth2
参见《芋道 Spring Security OAuth2 入门》文章,详细到爆炸。
6. 整合 JWT
参见《前后端分离 SpringBoot + SpringSecurity + JWT + RBAC 实现用户无状态请求验证》文章,写的很不错。
7. 项目实战
在开源项目翻了一圈,找到一个相对合适项目 RuoYi-Vue 。主要以下几点原因:
- 基于 Spring Security 实现。
- 基于 RBAC 权限模型,并且支持动态的权限配置。
- 基于 Redis 服务,实现登录用户的信息缓存。
- 前后端分离。同时前端采用 Vue ,相对来说后端会 Vue 的比 React 的多。
考虑到方便自己添加注释,艿艿 Fork 出一个仓库, 地址是 https://github.com/YunaiV/RuoYi-Vue 。
强烈推荐,生产级 Spring Security 项目实践,支持管理后台 + 用户 App 两种平台!
项目地址:https://github.com/YunaiV/ruoyi-vue-pro
🔥 官方推荐 🔥 RuoYi-Vue 全新 Pro 版本,优化重构所有功能。基于 Spring Boot + MyBatis Plus + Vue & Element 实现的后台管理系统 + 微信小程序,支持 RBAC 动态权限、数据权限、SaaS 多租户、Activiti + Flowable 工作流、三方登录、支付、短信、商城等功能。你的 ⭐️ Star ⭐️,是作者生发的动力!
下面,来跟着艿艿一起走读下 RuoYi-Vue 的权限相关功能。
7.1 表结构
基于 RBAC 权限模型,一共有 5 个表。
对 RBAC 权限模型不了解的胖友,可以看看《到底什么是RBAC权限模型?!》
😈 嘻嘻,艿艿的大学毕业设计,做的就是统一认证中心,2011 年的时候,前后端分离。前端采用 ExtJS 框架,后端自己参考 Spring Security 造的权限框架的轮子,提供 SDK 接入统一认证中心,使用 HTTP 通信。
实体 | 表 | 说明 |
---|---|---|
SysUser | sys_user |
用户信息 |
SysRole | sys_role |
用户信息 |
SysUserRole | sys_user_role |
用户和角色关联 |
SysMenu | sys_menu |
菜单权限 |
SysRoleMenu | sys_role_menu |
角色和菜单关联 |
5 个表的关系比较简单:
- 一个 SysUse ,可以拥有多个 SysRole ,通过 SysUserRole 存储关联。
- 一个 SysRole ,可以拥有多个 SysMenu ,通过 SysRoleMenu 存储关联。
7.1.1 SysUser
SysUser ,用户实体类。代码如下:
// SysUser.java |
- 添加
@Transient
注解的字段,非存储字段。后续的实体,补充重复赘述。 - 每个字段比较简单,胖友自己根据注释理解下即可。
对应表的创建 SQL 如下:
create table sys_user ( |
7.1.2 SysRole
SysRole ,角色实体类。代码如下:
// SysRole.java |
- 每个字段比较简单,胖友自己根据注释理解下即可。
对应表的创建 SQL 如下:
create table sys_role ( |
7.1.3 SysUserRole
SysUserRole ,用户和角色关联实体类。代码如下:
// SysUserRole.java |
- 每个字段比较简单,胖友自己根据注释理解下即可。
roleKey
属性,对应的角色标识字符串,可以对应多个角色标识,使用逗号分隔。例如说:"admin,normal"
。
对应表的创建 SQL 如下:
create table sys_user_role ( |
7.1.4 SysMenu
SysMenu ,菜单权限实体类。代码如下:
// SysMenu.java |
-
😈 个人感觉,这个实体改成 SysResource 资源,更加合适,菜单仅仅是其中的一种。
-
每个字段比较简单,胖友自己根据资源理解下即可。我们来重点看几个字段。
-
menuType
属性,定义了三种类型。其中,F
代表按钮,是为了做页面中的功能级的权限。 -
perms
属性,对应的权限标识字符串。一般格式为${大模块}:${小模块}:{操作}
。示例如下:用户查询:system:user:query
用户新增:system:user:add
用户修改:system:user:edit
用户删除:system:user:remove
用户导出:system:user:export
用户导入:system:user:import
重置密码:system:user:resetPwd- 对于前端来说,每个按钮在展示时,可以判断用户是否有该按钮的权限。如果没有,则进行隐藏。当然,前端在首次进入系统的时候,会请求一次权限列表到本地进行缓存。
- 对于后端来说,每个接口上会添加
@PreAuthorize("@ss.hasPermi('system:user:list')")
注解。在请求接口时,会校验用户是否有该 URL 对应的权限。如果没有,则会抛出权限验证失败的异常。 - 一个
perms
属性,可以对应多个权限标识,使用逗号分隔。例如说:"system:user:query,system:user:add"
。
对应表的创建 SQL 如下:
create table sys_menu ( |
7.1.5 SysRoleMenu
SysRoleMenu ,菜单权限实体类。代码如下:
// SysRoleMenu.java |
- 每个字段比较简单,胖友自己根据注释理解下即可。
对应表的创建 SQL 如下:
create table sys_role_menu ( |
7.2 SecurityConfig
在 SecurityConfig 配置类,继承 WebSecurityConfigurerAdapter 抽象类,实现 Spring Security 在 Web 场景下的自定义配置。代码如下:
// SecurityConfig.java |
- 涉及到的配置方法较多,我们逐个来看看。
重写 #configure(AuthenticationManagerBuilder auth)
方法,实现 AuthenticationManager 认证管理器。代码如下:
// SecurityConfig.java |
<X>
处,调用AuthenticationManagerBuilder#userDetailsService(userDetailsService)
方法,使用自定义实现的 UserDetailsService 实现类,更加灵活且自由的实现认证的用户信息的读取。在「7.3.1 加载用户信息」中,我们会看到 RuoYi-Vue 对 UserDetailsService 的自定义实现类。<Y>
处,调用AbstractDaoAuthenticationConfigurer#passwordEncoder(passwordEncoder)
方法,设置 PasswordEncoder 密码编码器。这里,就使用了 bCryptPasswordEncoder 强散列哈希加密实现。
重写 #configure(HttpSecurity httpSecurity)
方法,主要配置 URL 的权限控制。代码如下:
// SecurityConfig.java |
- 比较长,我们选择重点的来看。
<X>
处,设置认证失败时的处理器为unauthorizedHandler
。详细解析,见「7.6.1 AuthenticationEntryPointImpl」。<Y>
处,设置用于登录的/login
接口,允许匿名访问。这样,后续我们就可以使用自定义的登录接口。详细解析,见「7.3 登录 API 接口」。<Z>
处,设置登出成功的处理器为logoutSuccessHandler
。详细解析,见「7.6.3 LogoutSuccessHandlerImpl」。<P>
处,添加 JWT 认证过滤器authenticationTokenFilter
,用于用户使用用户名与密码登录完成后,后续请求基于 JWT 来认证。 详细解析,见「7.4 JwtAuthenticationTokenFilter」。
重写 #authenticationManagerBean
方法,解决无法直接注入 AuthenticationManager 的问题。代码如下:
// SecurityConfig.java |
- 在方法上,额外添加了
@Bean
注解,保证创建出 AuthenticationManager Bean 。
下面,我们详细的来看看,各个配置的 Bean 的逻辑。
7.3 登录 API 接口
SysLoginController#login(...)
在 SysLoginController 中,定义了 /login
接口,提供登录功能。代码如下:
// SysLoginController.java |
-
在内部,会调用
loginService#login(username, password, code, uuid)
方法,会进行基于用户名与密码的登录认证。认证通过后,返回身份 TOKEN 。 -
登录成功后,该接口响应示例如下
{
"msg": "操作成功",
"code": 200,
"token": "eyJhbGciOiJIUzUxMiJ9.eyJsb2dpbl91c2VyX2tleSI6ImJkN2Q4OTZiLTU2NTAtNGIyZS1iNjFjLTc0MjlkYmRkNzA1YyJ9.lkU8ot4GecLHs7VAcRAo1fLMOaFryd4W5Q_a2wzPwcOL0Kiwyd4enpnGd79A_aQczXC-JB8vELNcNn7BrtJn9A"
}- 后续,前端在请求后端接口时,在请求头上带头该
token
值,作为用户身份标识。
- 后续,前端在请求后端接口时,在请求头上带头该
SysLoginService#login(...)
在 SysLoginService
中,定义了 #login(username, password, code, uuid)
方法,进行基于用户名与密码的登录认证。认证通过后,返回身份 TOKEN 。代码如下:
// SysLoginService.java |
<1>
处,验证图片验证码的正确性。该验证码会存储在 Redis 缓存中,通过uuid
作为对应的标识。生成的逻辑,胖友自己看 CaptchaController 提供的/captchaImage
接口。<2>
处,调用 Spring Security 的 AuthenticationManager 的#authenticate(UsernamePasswordAuthenticationToken authentication)
方法,基于用户名与密码的登录认证。在其内部,会调用我们定义的 UserDetailsServiceImpl 的#loadUserByUsername(String username)
方法,获得指定用户名对应的用户信息。详细解析,见「7.3.1 加载用户信息」。<2.1>
处,发生异常,说明认证不通过,记录相应的登录失败日志。<2.2>
处,未发生异常,说明认证通过,记录相应的登录成功日志。- 关于上述日志,我们在「7.7 登录日志」来讲。
<3>
处,调用 TokenService 的#createToken(LoginUser loginUser)
方法,给认证通过的用户,生成其对应的认证 TOKEN 。这样,该用户的后续请求,就使用该 TOKEN 作为身份标识进行认证。
7.3.1 加载用户信息
在 UserDetailsServiceImpl 中,实现 Spring Security UserDetailsService 接口,实现了该接口定义的 #loadUserByUsername(String username)
方法,获得指定用户名对应的用户信息。代码如下:
// UserDetailsServiceImpl.java |
-
<1>
处,调用 ISysUserService 的#selectUserByUserName(String userName)
方法,查询指定用户名对应的 SysUser 。代码如下:// SysUserServiceImpl.java
private SysUserMapper userMapper;
public SysUser selectUserByUserName(String userName) {
return userMapper.selectUserByUserName(userName);
}
// SysUserMapper.XML
<sql id="selectUserVo">
select u.user_id, u.dept_id, u.user_name, u.nick_name, u.email, u.avatar, u.phonenumber, u.password, u.sex, u.status, u.del_flag, u.login_ip, u.login_date, u.create_by, u.create_time, u.remark,
d.dept_id, d.parent_id, d.dept_name, d.order_num, d.leader, d.status as dept_status,
r.role_id, r.role_name, r.role_key, r.role_sort, r.data_scope, r.status as role_status
from sys_user u
left join sys_dept d on u.dept_id = d.dept_id
left join sys_user_role ur on u.user_id = ur.user_id
left join sys_role r on r.role_id = ur.role_id
</sql>
<select id="selectUserByUserName" parameterType="String" resultMap="SysUserResult">
<include refid="selectUserVo"/>
where u.user_name = #{userName}
</select>- 通过查询
sys_user
表,同时连接sys_dept
、sys_user_role
、sys_role
表,将username
对应的 SysUser 相关信息都一次性查询出来。 - 返回结果
SysUserResult
的具体定义,点击 传送门 查看,实际就是 SysUser 实体类。
- 通过查询
-
<2>
处,各种校验。如果校验不通过,抛出 UsernameNotFoundException 或 BaseException 异常。 -
<3>
处,调用 SysPermissionService 的#getMenuPermission(SysUser user)
方法,获得用户的 SysRoleMenu 的权限标识字符串的集合。代码如下:// SysPermissionService.java
private ISysMenuService menuService;
public Set<String> getMenuPermission(SysUser user) {
Set<String> roles = new HashSet<String>();
// 管理员拥有所有权限
if (user.isAdmin()) {
roles.add("*:*:*"); // 所有模块
} else {
// 读取
roles.addAll(menuService.selectMenuPermsByUserId(user.getUserId()));
}
return roles;
}
// SysMenuServiceImpl.java
private SysMenuMapper menuMapper;
public Set<String> selectMenuPermsByUserId(Long userId) {
// 读取 SysMenu 的权限标识数组
List<String> perms = menuMapper.selectMenuPermsByUserId(userId);
// 逐个,按照“逗号”分隔
Set<String> permsSet = new HashSet<>();
for (String perm : perms) {
if (StringUtils.isNotEmpty(perm)) {
permsSet.addAll(Arrays.asList(perm.trim().split(",")));
}
}
return permsSet;
}
// SysMenuMapper.xml
<select id="selectMenuPermsByUserId" parameterType="Long" resultType="String">
select distinct m.perms
from sys_menu m
left join sys_role_menu rm on m.menu_id = rm.menu_id
left join sys_user_role ur on rm.role_id = ur.role_id
where ur.user_id = #{userId}
</select>- 虽然代码很长,但是核心的并不多。
- 首先,如果 SysUser 是超级管理员,则其权限标识集合就是
*:*:*
,标识可以所有模块的所有操作。 - 然后,查询
sys_menu
表,同时连接sys_role_menu
、sys_user_role
表,将 SysUser 拥有的 SysMenu 的权限标识数组,然后使用","
分隔每个 SysMenu 对应的权限标识。
这里,我们看到最终返回的是 LoginUser ,实现 Spring Security UserDetails 接口,自定义的用户明细。代码如下:
// LoginUser.java |
7.3.2 创建认证 Token
在 TokenService 中,定义了 #createToken(LoginUser loginUser)
方法,给认证通过的用户,生成其对应的认证 Token 。代码如下:
// TokenService.java |
-
注意,这个方法不仅仅会生成认证 Token ,还会缓存 LoginUser 到 Redis 缓存中。
-
<1>
处,设置 LoginUser 的用户唯一标识,即LoginUser.token
。注意,这里虽然变量名叫token
,其实不是身份认证的 Token 。 -
<2>
处,调用#setUserAgent(LoginUser loginUser)
方法,设置用户终端相关的信息,包括 IP、城市、浏览器、操作系统。代码如下:// TokenService.java
public void setUserAgent(LoginUser loginUser) {
UserAgent userAgent = UserAgent.parseUserAgentString(ServletUtils.getRequest().getHeader("User-Agent"));
String ip = IpUtils.getIpAddr(ServletUtils.getRequest());
loginUser.setIpaddr(ip);
loginUser.setLoginLocation(AddressUtils.getRealAddressByIP(ip));
loginUser.setBrowser(userAgent.getBrowser().getName());
loginUser.setOs(userAgent.getOperatingSystem().getName());
} -
<3>
处,调用#refreshToken(LoginUser loginUser)
方法,缓存 LoginUser 到 Redis 缓存中。代码如下:// application.yaml
# token配置
token:
# 令牌有效期(默认30分钟)
expireTime: 30
// Constants.java
/**
* 登录用户 redis key
*/
public static final String LOGIN_TOKEN_KEY = "login_tokens:";
// TokenService.java
// 令牌有效期(默认30分钟)
"${token.expireTime}") (
private int expireTime;
private RedisCache redisCache;
public void refreshToken(LoginUser loginUser) {
loginUser.setLoginTime(System.currentTimeMillis());
loginUser.setExpireTime(loginUser.getLoginTime() + expireTime * MILLIS_MINUTE);
// 根据 uuid 将 loginUser 缓存
String userKey = getTokenKey(loginUser.getToken());
redisCache.setCacheObject(userKey, loginUser, expireTime, TimeUnit.MINUTES);
}
private String getTokenKey(String uuid) {
return Constants.LOGIN_TOKEN_KEY + uuid;
}- 缓存的 Redis Key 的统一前缀为
"login_tokens:"
,使用 Login 的用户唯一标识(LoginUser.token
)作为后缀。
- 缓存的 Redis Key 的统一前缀为
-
<4>
处,调用#createToken(Map<String, Object> claims)
方法,生成 JWT 的 Token 。代码如下:// application.yaml
# token配置
token:
# 令牌秘钥
secret: abcdefghijklmnopqrstuvwxyz
// TokenService.java
// 令牌秘钥
"${token.secret}") (
private String secret;
private String createToken(Map<String, Object> claims) {
return Jwts.builder()
.setClaims(claims)
.signWith(SignatureAlgorithm.HS512, secret).compact();
}- 这里,我们采用了
jjwt
库。 - 对 JWT 不了解的胖友,可以阅读下《JSON Web Token - 在Web应用间安全地传递信息》和《八幅漫画理解使用 JSON Web Token 设计单点登录系统》文章。
- 注意,不要把这里生成的 JWT 的 Token ,和我们上面的
LoginUser.token
混淆在一起。- 因为
LoginUser.token
添加到claims
中,最终生成了 JWT 的 Token 。所以,后续我们可以通过解码该 JWT 的 Token ,从而获得claims
,最终获得到对应的LoginUser.token
。 - 在 JWT 的 Token 中,使用
LoginUser.token
而不是userId
的好处,可以带来更好的安全性,避免万一secret
秘钥泄露之后,黑客可以顺序生成userId
从而生成对应的 JWT 的 Token ,后续直接可以操作该用户的数据。不过,这么做之后就不是纯粹的 JWT ,解析出来的LoginUser.token
需要查询对应的 LoginUser 的 Redis 缓存。详细的,我们在「7.4 JwtAuthenticationTokenFilter」会看到这个过程。
- 因为
- 这里,我们采用了
至此,我们完成了基于用户名与密码的登录认证的整个过程。虽然整个过程的代码有小几百行,不过逻辑还是比较清晰明了的。如果不太理解的胖友,可以在反复看两遍。
7.4 JwtAuthenticationTokenFilter
在 JwtAuthenticationTokenFilter 中,继承 OncePerRequestFilter 过滤器,实现了基于 Token 的认证。代码如下:
// JwtAuthenticationTokenFilter.java |
-
<1>
处,调用 TokenService 的#getLoginUser(request)
方法,获得当前 LoginUser 。代码如下:// application.yaml
# token配置
token:
# 令牌自定义标识
header: Authorization
// TokenService.jav
// 令牌自定义标识
"${token.header}") (
private String header;
/**
* 获取用户身份信息
*
* @return 用户信息
*/
public LoginUser getLoginUser(HttpServletRequest request) {
// <1.1> 获取请求携带的令牌
String token = getToken(request);
if (StringUtils.isNotEmpty(token)) {
// <1.2> 解析 JWT 的 Token
Claims claims = parseToken(token);
// <1.3> 从 Redis 缓存中,获得对应的 LoginUser
String uuid = (String) claims.get(Constants.LOGIN_USER_KEY);
String userKey = getTokenKey(uuid);
return redisCache.getCacheObject(userKey);
}
return null;
}
private String getToken(HttpServletRequest request) {
String token = request.getHeader(header);
if (StringUtils.isNotEmpty(token) && token.startsWith(Constants.TOKEN_PREFIX)) {
token = token.replace(Constants.TOKEN_PREFIX, "");
}
return token;
}
private Claims parseToken(String token) {
return Jwts.parser()
.setSigningKey(secret)
.parseClaimsJws(token)
.getBody();
}<1.1>
处,调用#getToken(request)
方法,从请求头"Authorization"
中,获得身份认证的 Token 。<1.2>
处,调用#parseToken(token)
方法,解析 JWT 的 Token ,获得 Claims 对象,从而获得用户唯一标识(LoginUser.token
)。<1.3>
处,从 Redis 缓存中,获得对应的 LoginUser 。
-
<2>
处,调用 TokenService 的#verifyToken(LoginUser loginUser)
方法,验证令牌有效期。代码如下:// TokenService.java
protected static final long MILLIS_SECOND = 1000;
protected static final long MILLIS_MINUTE = 60 * MILLIS_SECOND;
private static final Long MILLIS_MINUTE_TEN = 20 * 60 * 1000L;
/**
* 验证令牌有效期,相差不足 20 分钟,自动刷新缓存
*
* @param loginUser 用户
*/
public void verifyToken(LoginUser loginUser) {
long expireTime = loginUser.getExpireTime();
long currentTime = System.currentTimeMillis();
// 相差不足 20 分钟,自动刷新缓存
if (expireTime - currentTime <= MILLIS_MINUTE_TEN) {
String token = loginUser.getToken();
loginUser.setToken(token);
refreshToken(loginUser);
}
}- 实际上,这个方法的目的不是验证 Token 的有效性,而是刷新对应的 LoginUser 的缓存的过期时间。
- 考虑到避免每次请求都去刷新缓存的过期时间,所以过期时间不足 20 分钟,才会去刷新。
-
<3>
处,手动创建 UsernamePasswordAuthenticationToken 对象,设置到 SecurityContextHolder 中。😈 因为,我们已经通过 Token 来完成认证了。 -
<4>
处,继续过滤器。
严格来说,RuoYi-Vue 并不是采用的无状态的 JWT ,而是使用 JWT 的 Token 的生成方式。
7.5 权限验证
在「3. 进阶使用」中,我们看到可以通过 Spring Security 提供的 @PreAuthorize
注解,实现基于 Spring EL 表达式的执行结果为 true
时,可以访问,从而实现灵活的权限校验。
在 RuoYi-Vue 中,通过 @PreAuthorize
注解的特性,使用其 PermissionService 提供的权限验证的方法。使用示例如下:
// SysDictDataController.java |
- 请求
/system/dict/data/list
接口,会调用 PermissionService 的#hasPermi(String permission)
方法,校验用户是否有指定的权限。 - 为什么这里会有一个
@ss
呢?在 Spring EL 表达式中,调用指定 Bean 名字的方法时,使用@
+ Bean 的名字。在 RuoYi-Vue 中,声明 PermissionService 的 Bean 名字为ss
。
7.5.1 判断是否有权限
在 PermissionService 中,定义了 #hasPermi(String permission)
方法,判断当前用户是否有指定的权限。代码如下:
// PermissionService.java |
- 比较简单,胖友看看艿艿添加的代码注释,就能够明白。
在 PermissionService 中,定义了 #lacksPermi(String permission)
方法,判断当前用户是否没有指定的权限。代码如下:
// PermissionService.java |
在 PermissionService 中,定义了 #hasAnyPermi(String permissions)
方法,判断当前用户是否有指定的任一权限。代码如下:
// PermissionService.java |
7.5.2 判断是否有角色
在 PermissionService 中,定义了 #hasRole(String role)
方法,判断当前用户是否有指定的角色。代码如下:
// PermissionService.java |
- 比较简单,胖友看看艿艿添加的代码注释,就能够明白。
在 PermissionService 中,定义了 #lacksRole(String role)
方法,判断当前用户是否没有指定的角色。代码如下:
// PermissionService.java |
在 PermissionService 中,定义了 #hasAnyRoles(String roles)
方法,判断当前用户是否有指定的任一角色。代码如下:
// PermissionService.java |
7.6 各种处理器
在 Ruoyi-Vue 中,提供了各种处理器,处理各种情况,所以我们汇总在「7.6 各种处理器」 中,一起来瞅瞅。
7.6.1 AuthenticationEntryPointImpl
在 AuthenticationEntryPointImpl 中,实现 Spring Security AuthenticationEntryPoint 接口,处理认失败的 AuthenticationException 异常。代码如下:
// AuthenticationEntryPointImpl.java |
- 响应认证不通过的 JSON 字符串。
7.6.2 GlobalExceptionHandler
在 GlobalExceptionHandler 中,定义了对 Spring Security 的异常处理。代码如下:
// GlobalExceptionHandler.java |
- 基于 Spring MVC 提供的
@RestControllerAdvice
+@ExceptionHandler
注解,实现全局异常的处理。不了解的胖友,可以看看《芋道 Spring Boot SpringMVC 入门》的「5. 全局异常处理」小节。
7.6.3 LogoutSuccessHandlerImpl
在 LogoutSuccessHandlerImpl 中,实现 Spring Security LogoutSuccessHandler 接口,自定义退出的处理,主动删除 LoginUser 在 Redis 中的缓存。代码如下:
// LogoutSuccessHandlerImpl.java |
-
<1>
处,调用 TokenService 的#getLoginUser(request)
方法,获得当前 LoginUser 。 -
<2>
处,调用 TokenService 的#delLoginUser(String token)
方法,删除 LoginUser 的 Redis 缓存。代码如下:// TokenService.java
public void delLoginUser(String token) {
if (StringUtils.isNotEmpty(token)) {
String userKey = getTokenKey(token);
// 删除缓存
redisCache.deleteObject(userKey);
}
} -
<3>
处,记录相应的退出成功日志。 -
<4>
处,响应退出成功的 JSON 字符串。
7.7 登录日志
SysLogininfor ,登录日志实体。代码如下:
// SysLogininfor.java |
- 每个字段比较简单,胖友自己根据注释理解下即可。
对应表的创建 SQL 如下:
create table sys_logininfor ( |
在 RuoYi-Vue 中,记录 SysLogininfor 的过程如下:
- 首先,手动调用
AsyncFactory#recordLogininfor(username, status, message, args)
方法,创建一个 Java TimerTask 任务。 - 然后调用
AsyncManager#execute(TimerTask task)
方法,提交到定时任务的线程中,延迟OPERATE_DELAY_TIME = 10
秒后,存储该记录到数据库中。
这样的好处,是可以实现异步存储日志到数据库中,提升 API 接口的性能。不过实际上,Spring 提供了 @Async
注解,方便的实现异步操作。不了解的胖友,可以看看《芋道 Spring Boot 异步任务入门》。
另外,在 RuoYi-Vue 中还定义了 SysOperLog ,操作日志实体类。感兴趣的胖友,自己去瞅瞅。
7.8 获得用户信息 API 接口
在 SysLoginController 中,定义了 /getInfo
接口,获取登录的用户信息。代码如下:
// SysLoginController.java |
-
<1>
处,调用 TokenService 的#getLoginUser(request)
方法,获得当前 LoginUser 。 -
<2>
处,调用 PermissionService 的#getRolePermission(SysUser user)
方法,获得 LoginUser 拥有的角色标识的集合。代码如下:// SysPermissionService.java
private ISysRoleService roleService;
/**
* 获取角色数据权限
*
* @param user 用户信息
* @return 角色权限信息
*/
public Set<String> getRolePermission(SysUser user) {
Set<String> roles = new HashSet<String>();
// 管理员拥有所有权限
if (user.isAdmin()) { // 如果是管理员,强制添加 admin 角色
roles.add("admin");
} else { // 如果非管理员,进行查询
roles.addAll(roleService.selectRolePermissionByUserId(user.getUserId()));
}
return roles;
}
// SysRoleServiceImpl.java
private SysRoleMapper roleMapper;
/**
* 根据用户ID查询权限
*
* @param userId 用户ID
* @return 权限列表
*/
public Set<String> selectRolePermissionByUserId(Long userId) {
// 获得 userId 拥有的 SysRole 数组
List<SysRole> perms = roleMapper.selectRolePermissionByUserId(userId);
// 遍历 SysRole 数组,生成角色标识数组
Set<String> permsSet = new HashSet<>();
for (SysRole perm : perms) {
if (StringUtils.isNotNull(perm)) {
permsSet.addAll(Arrays.asList(perm.getRoleKey().trim().split(",")));
}
}
return permsSet;
}
// SysRoleMapper.xml
<sql id="selectRoleVo">
select distinct r.role_id, r.role_name, r.role_key, r.role_sort, r.data_scope,
r.status, r.del_flag, r.create_time, r.remark
from sys_role r
left join sys_user_role ur on ur.role_id = r.role_id
left join sys_user u on u.user_id = ur.user_id
left join sys_dept d on u.dept_id = d.dept_id
</sql>
<select id="selectRolePermissionByUserId" parameterType="Long" resultMap="SysRoleResult">
<include refid="selectRoleVo"/>
WHERE r.del_flag = '0' and ur.user_id = #{userId}
</select>- 通过查询
sys_role
表,同时连接sys_user_role
、sys_user
、sys_dept
表,将userId
对应的 SysRole 相关信息都一次性查询出来。 - 返回结果 SysRoleResult 的具体定义,点击 传送门 查看,实际就是 SysRole 实体类。
- 通过查询
-
<3>
处,调用 SysPermissionService 的#getMenuPermission(SysUser user)
方法,获得用户的 SysRoleMenu 的权限标识字符串的集合。 -
<4>
处,返回用户信息的 AjaxResult 结果。
通过调用该 /getInfo
接口,前端就可以根据角色标识、又或者权限标识,实现对页面级别的按钮实现权限控制,进行有权限时显示,无权限时隐藏。
7.9 获取路由信息
在 SysLoginController 中,定义了 /getRouters
接口,获取获取路由信息。代码如下:
// SysLoginController.java |
- 具体的代码,比较简单,胖友自己去阅读下,嘿嘿。
通过调用该 /getRouters
接口,前端就可以构建管理后台的左边菜单。
7.10 权限管理
如下的 Controller ,提供了 RuoYi-Vue 的权限管理功能,比较简单,胖友自己去瞅瞅即可。
- 用户管理 SysUserController :用户是系统操作者,该功能主要完成系统用户配置。
- 角色管理 SysRoleController :角色菜单权限分配、设置角色按机构进行数据范围权限划分。
- 菜单管理 SysMenuController :配置系统菜单,操作权限,按钮权限标识等。
7.11 小小的建议
至此,我们完成了对 RuoYi-Vue 权限相关功能的源码进行解读,希望对胖友有一定的胖友。如果胖友项目中需要权限相关的功能,建议不要直接拷贝 RuoYi-Vue 的代码,而是按照自己的理解,一点点“重新”实现一遍。在这个过程中,我们会有更加深刻的理解,甚至会有自己的一些小创新。
666. 彩蛋
相对还是比较良心的一篇文章,胖友你说是不是,嘿嘿。
这里额外在推荐一些 RabbitMQ 不错的内容:
不过艿艿实际项目中,并未采用 Spring Security 或是 Shiro 安全框架,而是自己团队开发了一个相对轻量级的组件。主要考虑,目前前后端分离之后,Spring Security 内置的很多功能,已经不太需要,在加上拓展一些功能不是非常方便,有点“曲折”,所以才选择自己开发。