34.Spring Boot Shiro 入门
34.Spring Boot Shiro 入门
1. 概述
艿艿:本文是《芋道 Spring Boot 安全框架 Spring Security 入门》 的姊妹篇,所以开头就"重复"再来一遍,嘿嘿。
基本上,在所有的开发的系统中,都必须做认证(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 两个安全框架,可以完成认证和授权的功能。本文,我们再来学习下 Apache Shiro 。其官方对自己介绍如下:
FROM 《Apache Shiro 官网》
Apache Shiro™ is a powerful and easy-to-use Java security framework that performs authentication, authorization, cryptography, and session management.
Apache Shiro™ 是一个功能强大且易于使用的 Java 安全框架,它可以提供身份验证、授权、加密和会话管理的功能。With Shiro's easy-to-understand API, you can quickly and easily secure any application -- from the smallest mobile applications to the largest web and enterprise applications.
通过 Shiro 易于理解的 API ,你可以快速、轻松地保护任何应用程序 ------ 从最小的移动端应用程序到大型的的 Web 和企业级应用程序。
更多关于 Shiro 的介绍,胖友可以自行阅读《Apache Shiro 1.2.x 参考手册》 ,虽然目前 Shiro 截止到目前已经发布到 1.4.2 版本,但是该手册依然很有参考价值。
2. 快速入门
示例代码对应仓库:lab-33-shiro-demo 。
在本小节中,我们来对 Shiro 进行快速的入门,实现一个最小化的使用示例。
2.1 引入依赖
在 pom.xml
文件中,引入相关依赖。
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.1.10.RELEASE</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<modelVersion>4.0.0</modelVersion>
<artifactId>lab-33-shiro-demo</artifactId>
<dependencies>
<!-- 实现对 Spring MVC 的自动化配置 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- 实现对 Shiro 的自动化配置 -->
<dependency>
<groupId>org.apache.shiro</groupId>
<artifactId>shiro-spring-boot-starter</artifactId>
<version>1.4.2</version>
</dependency>
</dependencies>
</project>
不过实际上,shiro-spring-boot-starter
依赖对 Shiro 的自动化配置基本没啥用,需要自己来主动实现对 Shiro 的配置。
2.2 ShiroConfig
在 cn.iocoder.springboot.lab01.shirodemo.config
包下,创建 ShiroConfig 抽象类,实现 Shiro 的自定义配置。代码如下:
// ShiroConfig.java
@Configuration
public class ShiroConfig {
@Bean
public Realm realm() { /**省略代码**/ }
@Bean
public DefaultWebSecurityManager securityManager() { /**省略代码**/ }
@Bean
public ShiroFilterFactoryBean shiroFilterFactoryBean() { /**省略代码**/ }
}
- 一共有三个 Bean 的配置,我们逐个来看看。
2.2.1 Realm
我们先来看看 Realm 的定义。
《Apache Shiro 1.2.x 参考手册 ------ Realms》
Realm 是可以访问程序特定的安全数据如用户、角色、权限等的一个组件。Realm 会将这些程序特定的安全数据转换成一种 Shiro 可以理解的形式,Shiro 就可以依次提供容易理解的 Subject 程序API而不管有多少数据源或者程序中你的数据如何组织。
Realm 通常和数据源是一对一的对应关系,如关系数据库,LDAP 目录,文件系统,或其他类似资源。因此,Realm 接口的实现使用数据源特定的API 来展示授权数据(角色,权限等),如JDBC,文件IO,Hibernate 或JPA,或其他数据访问API。
Realm 实质上就是一个特定安全的 DAO
因为这些数据源大多通常存储身份验证数据(如密码的凭证)以及授权数据(如角色或权限),每个 Shiro Realm 能够执行身份验证和授权操作。
- 😈 好长一段描述,抓重点,最后一句的"身份验证 "(认证)和"授权",这个就是 Realm 的职责。
Realm 整体的类图如下:
- Realm 接口,主要定义了**"认证"**方法。代码如下:
// Realm.java
AuthenticationInfo getAuthenticationInfo(AuthenticationToken token) throws AuthenticationException;
- AuthorizingRealm 抽象类,主要额外 定义了授权方法。代码如下:
// AuthorizingRealm.java
protected abstract AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals);
- AuthorizingRealm 同时实现了 Authorizer 接口,提供判断经过认证过的 Subject 是否具有指定的角色、权限的方法。
- 从图中我们可以看出,Shiro 提供了多种 AuthorizingRealm 的实现类,提供从不同的数据源获取数据。不过一般在项目中,我们会自定义实现 AuthorizingRealm ,从自己定义的表结构 中读取用户、角色、权限等数据。虽然说,Shiro 提供了 JdbcRealm 可以访问数据库,但是它的表结构是固定的 ,所说我们才要自定义定义实现 AuthorizingRealm 。
本示例中,在 #realm()
方法,我们创建了 SimpleAccountRealm Bean 对象。代码如下:
// ShiroConfig.java
@Bean
public Realm realm() {
// 创建 SimpleAccountRealm 对象
SimpleAccountRealm realm = new SimpleAccountRealm();
// 添加两个用户。参数分别是 username、password、roles 。
realm.addAccount("admin", "admin", "ADMIN");
realm.addAccount("normal", "normal", "NORMAL");
return realm;
}
- SimpleAccountRealm 是使用内存 作为数据源,我们可以手动往里面添加用户、角色、权限等数据。😈 毕竟作为一个示例,艿艿不想引入数据库,增加复杂性。不过我们在「3. 项目实战」中,我们会看到我们使用自定义的 AuthorizingRealm 实现类。
- 在该方法里,我们添加了「admin/admin」和「normal/normal」两个用户,分别对应 ADMIN 和 NORMAL 角色。
2.2.2 SecurityManager
我们再来看看 SecurityManager 的定义。
《Apache Shiro 1.2.x 参考手册 ------ Session Management》
SecurityManager 是 Shiro 架构的核心,配合内部安全组件共同组成安全伞。
本示例中,在 #securityManager()
方法,我们创建了 DefaultWebSecurityManager Bean 对象。代码如下:
// ShiroConfig.java
@Bean
public DefaultWebSecurityManager securityManager() {
// 创建 DefaultWebSecurityManager 对象
DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager();
// 设置其使用的 Realm
securityManager.setRealm(this.realm());
return securityManager;
}
- 不用特别去纠结 SecurityManager ,创建好 DefaultWebSecurityManager Bean 就完事了~等后续我们入门完 Shiro 之后,胖友可以在慢慢细细去研究。
2.2.3 ShiroFilter
通过 AbstractShiroFilter 过滤器 ,实现对请求的拦截,从而实现 Shiro 的功能。AbstractShiroFilter 整体的类图如下:
本示例中,在 #shiroFilterFactoryBean()
方法,我们创建了 ShiroFilterFactoryBean Bean 对象。代码如下:
// ShiroConfig.java
@Bean
public ShiroFilterFactoryBean shiroFilterFactoryBean() {
// <1> 创建 ShiroFilterFactoryBean 对象,用于创建 ShiroFilter 过滤器
ShiroFilterFactoryBean filterFactoryBean = new ShiroFilterFactoryBean();
// <2> 设置 SecurityManager
filterFactoryBean.setSecurityManager(this.securityManager());
// <3> 设置 URL 们
filterFactoryBean.setLoginUrl("/login"); // 登陆 URL
filterFactoryBean.setSuccessUrl("/login_success"); // 登陆成功 URL
filterFactoryBean.setUnauthorizedUrl("/unauthorized"); // 无权限 URL
// <4> 设置 URL 的权限配置
filterFactoryBean.setFilterChainDefinitionMap(this.filterChainDefinitionMap());
return filterFactoryBean;
}
<1>
处,创建 ShiroFilterFactoryBean 对象,用于创建 SpringShiroFilter 过滤器。<2>
处,设置其 SecurityManager 属性。<3>
处,设置各种 URL 。#setLoginUrl(String loginUrl)
方法,设置登陆 URL 。在 Shiro 中,约定GET loginUrl
为登陆页面,POST loginUrl
为登陆请求。#setSuccessUrl(String successUrl)
方法,设置登陆成功 URL 。在登陆成功时,会重定向到该 URL 上。#etUnauthorizedUrl(String unauthorizedUrl)
方法,设置无权限的 URL 。在请求校验权限不通过时,会重定向到该 URL 上。- 上述的 URL 对应的接口,都需要我们自己来实现。具体可见「2.3 SecurityController」小节。
<4>
处,调用#setFilterChainDefinitionMap(Map<String, String> filterChainDefinitionMap)
方法,设置 URL 的权限配置。
在看 #filterChainDefinitionMap()
方法的具体 URL 的权限配置之前,我们先来了解下 Shiro 内置的过滤器们。在 Shiro DefaultFilter 枚举类中,枚举了这些过滤器,以及其配置名。整理表格如下:
比较常用的过来器有:
anon
:AnonymousFilter :允许匿名访问,即无需登陆。authc
:FormAuthenticationFilter :需要经过认证的用户,才可以访问。如果是匿名用户,则根据 URL 不同,会有不同的处理:- 如果拦截的 URL 是
GET loginUrl
登陆页面,则进行该请求,跳转到登陆页面。 - 如果拦截的 URL 是
POST loginUrl
登陆请求,则基于请求表单的username
、password
进行认证。认证通过后,默认 重定向到GET loginSuccessUrl
地址。 - 如果拦截的 URL 是其它 URL 时,则记录该 URL 到 Session 中。在用户登陆成功后,重定向到该 URL 上。
- 如果拦截的 URL 是
logout
:LogoutFilter :拦截的 URL ,执行退出操作。退出完成后,重定向 到GET loginUrl
登陆页面。roles
:RolesAuthorizationFilter :拥有指定角色的用户可访问。perms
:PermissionsAuthorizationFilter :拥有指定权限的用户可以访问。
下面,让我们回过头来看看 #filterChainDefinitionMap()
方法的具体 URL 的权限配置。代码如下:
// ShiroConfig.java
private Map<String, String> filterChainDefinitionMap() {
Map<String, String> filterMap = new LinkedHashMap<>(); // 注意要使用有序的 LinkedHashMap ,顺序匹配
filterMap.put("/test/echo", "anon"); // 允许匿名访问
filterMap.put("/test/admin", "roles[ADMIN]"); // 需要 ADMIN 角色
filterMap.put("/test/normal", "roles[NORMAL]"); // 需要 NORMAL 角色
filterMap.put("/logout", "logout"); // 退出
filterMap.put("/**", "authc"); // 默认剩余的 URL ,需要经过认证
return filterMap;
}
/test/echo
:我们设置为anon
,允许匿名访问。/test/admin
和/test/normal
:我们设置为roles[...]
,需要指定角色的用户可以访问。其中...
处为需要添加的角色名。/logout
:我们设置为logout
,实现退出操作。/**
:剩余的 URL ,我们设置为authc
,需要登陆的用户才可以访问。同时,对于loginUrl
需要执行登陆相关的拦截。
另外,这里在补充一点,请求在 ShiroFilter 拦截之后,会根据该请求的情况,匹配到配置的内置的 Shiro Filter 们 ,逐个进行处理。也就是说,ShiroFilter 实际内部有一个由 内置的 Shiro Filter 组成的过滤器链。
至此,我们已经完成了 Shiro 的自定义配置。虽然篇幅有点长,但是可以等我们跑完整个「2. 快速入门」的示例之后,胖友再自己回过头来看看,会发现还是比较清晰明了的。
2.3 SecurityController
在 cn.iocoder.springboot.lab01.shirodemo.controller
包路径下,创建 SecurityController 类,提供登陆、登陆成功等接口。代码如下:
// SecurityController.java
@Controller
@RequestMapping("/")
public class SecurityController {
private Logger logger = LoggerFactory.getLogger(getClass());
@GetMapping("/login")
public String loginPage() { /**省略代码**/ }
@ResponseBody
@PostMapping("/login")
public String login(HttpServletRequest request) { /**省略代码**/ }
@ResponseBody
@GetMapping("/login_success")
public String loginSuccess() { /**省略代码**/ }
@ResponseBody
@GetMapping("/unauthorized")
public String unauthorized() { /**省略代码**/ }
}
- 一共有 4 个接口,我们逐个来看看。
2.3.1 登陆页面
GET /login
地址,跳转登陆页面。代码如下:
// SecurityController.java
@GetMapping("/login")
public String loginPage() {
return "login.html";
}
- 返回
resources/static/login.html
静态页面。代码如下:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>登陆页面</title>
</head>
<body>
<form action="/login" method="post">
用户名:<input type="text" name="username"/> <br />
密码:<input type="password" name="password"/> <br />
<input type="submit" value="登录"/>
</form>
</body>
</html>
- 一个简单的登陆的表单,
POST
提交登陆请求到/login
地址上。
- 一个简单的登陆的表单,
2.3.2 登陆请求
对于登陆请求,会被我们配置的 Shiro FormAuthenticationFilter 过滤器进行拦截,进行用户的身份认证。整个过程如下:
- FormAuthenticationFilter 解析请求的
username
、password
参数,创建 UsernamePasswordToken 对象。 - 然后,调用 SecurityManager 的
#login(Subject subject, AuthenticationToken authenticationToken)
方法,执行登陆操作,进行"身份验证"(认证)。 - 在这内部中,调用 Realm 的
#getAuthenticationInfo(AuthenticationToken token)
方法,进行认证。此时,根据认证的是否成功,会有不同的处理:- 如果认证通过,则 FormAuthenticationFilter 会将请求重定向 到
GET loginSuccess
地址上。 - 【重要】如果认证失败,则会将认证失败的原因设置到请求的
attributes
中,后续该请求会继续 请求到POST login
地址上。这样,在POST loginUrl
地址上,我们可以从attributes
中获取到失败的原因,提示给用户。
- 如果认证通过,则 FormAuthenticationFilter 会将请求重定向 到
所以,POST loginUrl
的目的,实际是为了处理认真失败的情况。也因此,POST login
地址,实现代码如下:
// SecurityController.java
@ResponseBody
@PostMapping("/login")
public String login(HttpServletRequest request) {
// <1> 判断是否已经登陆
Subject subject = SecurityUtils.getSubject();
if (subject.getPrincipal() != null) {
return "你已经登陆账号:" + subject.getPrincipal();
}
// <2> 获得登陆失败的原因
String shiroLoginFailure = (String) request.getAttribute(FormAuthenticationFilter.DEFAULT_ERROR_KEY_ATTRIBUTE_NAME);
// 翻译成人类看的懂的提示
String msg = "";
if (UnknownAccountException.class.getName().equals(shiroLoginFailure)) {
msg = "账号不存在";
} else if (IncorrectCredentialsException.class.getName().equals(shiroLoginFailure)) {
msg = "密码不正确";
} else if (LockedAccountException.class.getName().equals(shiroLoginFailure)) {
msg = "账号被锁定";
} else if (ExpiredCredentialsException.class.getName().equals(shiroLoginFailure)) {
msg = "账号已过期";
} else {
msg = "未知";
logger.error("[login][未知登陆错误:{}]", shiroLoginFailure);
}
return "登陆失败,原因:" + msg;
}
<1>
处,对于已经登陆成功的用户,如果我们再次请求POST loginUrl
地址,依然会直接跳转到该地址上。此处,我们是提供用户已经的登陆。😈 可能有部分胖友会希望重新进行一次登陆的逻辑,那么就需要重写 FormAuthenticationFilter 过滤器。<2>
处,从请求的attributes
中,获取FormAuthenticationFilter.DEFAULT_ERROR_KEY_ATTRIBUTE_NAME
对应的值,即登陆失败的原因。从代码中,我们可以看出,失败原因为异常的全类名,我们需要进行翻译成人类可读的提示。
2.3.3 登陆成功
GET login_success
地址,登陆成功响应。代码如下:
// SecurityController.java
@ResponseBody
@GetMapping("/login_success")
public String loginSuccess() {
return "登陆成功";
}
- 如果是 AJAX 请求的情况下,我们可以返回 JSON 字符串。例如说,用户、角色、权限等等信息。
- 如果非 AJAX 请求的情况下,重定向到登陆成功的页面。例如说,管理后台的 HOME 页面。
2.3.4 未授权
GET unauthorized
地址,未授权响应。代码如下:
// SecurityController.java
@ResponseBody
@GetMapping("/unauthorized")
public String unauthorized() {
return "你没有权限";
}
- 如果是 AJAX 请求的情况下,我们可以返回 JSON 字符串。例如说,你没有权限。
- 如果非 AJAX 请求的情况下,重定向到登陆成功的页面。例如说,未授权的页面。
2.4 TestController
在 cn.iocoder.springboot.lab01.shirodemo.controller
包路径下,创建 TestController 类,提供测试 API 接口。代码如下:
// TestController.java
@RestController
@RequestMapping("/test")
public class TestController {
@GetMapping("/demo")
public String demo() {
return "示例返回";
}
@GetMapping("/home")
public String home() {
return "我是首页";
}
@GetMapping("/admin")
public String admin() {
return "我是管理员";
}
@GetMapping("/normal")
public String normal() {
return "我是普通用户";
}
}
- 对于
/test/demo
接口,直接访问,无需登陆。 - 对于
/test/home
接口,无法直接访问,需要进行登陆。 - 对于
/test/admin
接口,需要登陆「admin/admin」用户,因为需要 ADMIN 角色。 - 对于
/test/normal
接口,需要登陆「user/user」用户,因为需要 USER 角色。
胖友可以按照如上的说明,进行各种测试。例如说,登陆「user/user」用户后,去访问 /test/admin
接口,会返回无权限的提示~
2.5 Application
创建 Application.java
类,配置 @SpringBootApplication
注解即可。代码如下:
// Application.java
@SpringBootApplication
public class Application {
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
}
至此,我们已经完成了 Shiro 的入门。美滋滋,胖友可以自己多多测试一下。
3. Shiro 注解
在 Shiro 中,提供了如下五个注解,可以直接添加在 SpringMVC 的 URL 对应的方法上,实现权限配置。下面,我们来分别看看。
3.1 @RequiresGuest
@RequiresGuest
注解,和 anon
等价。
3.2 @RequiresAuthentication
@RequiresAuthentication
注解,和 authc
等价。
3.3 @RequiresUser
@RequiresUser
注解,和 user
等价,要求必须登陆。
3.4 @RequiresRoles
@RequiresRoles
注解,和 roles
等价。代码如下:
// RequiresRoles.java
@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface RequiresRoles {
/**
* A single String role name or multiple comma-delimited role names required in order for the method
* invocation to be allowed.
*/
String[] value();
/**
* The logical operation for the permission check in case multiple roles are specified. AND is the default
* @since 1.1.0
* 当有多个角色时,AND 表示要拥有全部角色,OR 表示拥有任一角色即可
*/
Logical logical() default Logical.AND;
}
使用示例如下:
// 属于 NORMAL 角色
@RequiresRoles("NORMAL")
// 要同时拥有 ADMIN 和 NORMAL 角色
@RequiresRoles({"ADMIN", "NORMAL"})
// 拥有 ADMIN 或 NORMAL 任一角色即可
@RequiresRoles(value = {"ADMIN", "NORMAL"}, logical = Logical.OR)
如果验证权限不通过,则会抛出 AuthorizationException 异常。此时,我们可以基于 Spring MVC 提供的 @RestControllerAdvice
+ @ExceptionHandler
注解,实现全局异常的处理。不了解的胖友,可以看看《芋道 Spring Boot SpringMVC 入门》的「5. 全局异常处理」小节。
3.5 @RequiresPermissions
@RequiresPermissions
注解,和 perms
等价。代码如下:
// RequiresPermissions.java
@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface RequiresPermissions {
/**
* The permission string which will be passed to {@link org.apache.shiro.subject.Subject#isPermitted(String)}
* to determine if the user is allowed to invoke the code protected by this annotation.
*/
String[] value();
/**
* The logical operation for the permission checks in case multiple roles are specified. AND is the default
* @since 1.1.0
* 当有多个权限时,AND 表示要拥有全部权限,OR 表示拥有任一权限即可
*/
Logical logical() default Logical.AND;
}
使用示例如下:
// 拥有 user:add 权限
@RequiresPermissions("user:add")
// 要同时拥有 user:add 和 user:update 权限
@RequiresPermissions({"user:add", "user:update"})
// 拥有 user:add 和 user:update 任一权限即可
@RequiresPermissions(value = {"user:add", "user:update"}, logical = Logical.OR)
如果验证权限不通过,则会抛出 AuthorizationException 异常。此时,我们可以基于 Spring MVC 提供的 @RestControllerAdvice
+ @ExceptionHandler
注解,实现全局异常的处理。不了解的胖友,可以看看《芋道 Spring Boot SpringMVC 入门》的「5. 全局异常处理」小节。
3.6 使用示例
在 lab-33-shiro-demo 示例的基础上,我们进行修改,增加 Shiro 注解的使用。
在 cn.iocoder.springboot.lab01.shirodemo.controller
包路径下,创建 DemoController 类,提供示例 API 接口。代码如下:
// DemoController.java
@RestController
@RequestMapping("/demo")
public class DemoController {
@RequiresGuest
@GetMapping("/echo")
public String demo() {
return "示例返回";
}
@GetMapping("/home")
public String home() {
return "我是首页";
}
@RequiresRoles("ADMIN")
@GetMapping("/admin")
public String admin() {
return "我是管理员";
}
@RequiresRoles("NORMAL")
@GetMapping("/normal")
public String normal() {
return "我是普通用户";
}
}
- 每个 URL 的权限验证,和「3.2.2 TestController」是一一对应的。
胖友可以按照如上的说明,进行各种测试。例如说,登陆「user/user」用户后,去访问 /demo/admin
接口,会返回无权限的提示~
4. 项目实战
在开源项目翻了一圈,找到一个相对合适项目 renren-fast 。主要以下几点原因:
- 基于 Shiro 实现。
- 基于 RBAC 权限模型,并且支持动态的权限配置。
- 基于 OAuth2 授权认证。
- 前后端分离。同时前端采用 Vue ,相对来说后端会 Vue 的比 React 的多。
考虑到方便自己添加注释,艿艿 Fork 出一个仓库, 地址是 https://github.com/YunaiV/renren-fast 。
下面,来跟着艿艿一起走读下 renren-fast 的权限相关功能。
4.1 表结构
基于 RBAC 权限模型,一共有 5 个表。
对 RBAC 权限模型不了解的胖友,可以看看《到底什么是RBAC权限模型?!》
😈 嘻嘻,艿艿的大学毕业设计,做的就是统一认证中心,2011 年的时候,前后端分离。前端采用 ExtJS 框架,后端自己参考 Spring Security 造的权限框架的轮子,提供 SDK 接入统一认证中心,使用 HTTP 通信。
实体 | 表 | 说明 |
---|---|---|
SysUserEntity | sys_user | 用户信息 |
SysRoleEntity | sys_role | 用户信息 |
SysUserRoleEntity | sys_user_role | 用户和角色关联 |
SysMenuEntity | sys_menu | 菜单权限 |
SysRoleMenuEntity | sys_role_menu | 角色和菜单关联 |
5 个表的关系比较简单:
- 一个 SysUse ,可以拥有多个 SysRole ,通过 SysUserRole 存储关联。
- 一个 SysRole ,可以拥有多个 SysMenu ,通过 SysRoleMenu 存储关联。
4.1.1 SysUserEntity
SysUserEntity ,用户实体类。代码如下:
// SysUserEntity.java
@Data
@TableName("sys_user")
public class SysUserEntity implements Serializable {
private static final long serialVersionUID = 1L;
/** 用户ID */
@TableId
private Long userId;
@NotBlank(message = "用户名不能为空", groups = {AddGroup.class, UpdateGroup.class})
private String username;
@NotBlank(message = "密码不能为空", groups = AddGroup.class)
private String password;
/** 盐 */
private String salt;
@NotBlank(message = "邮箱不能为空", groups = {AddGroup.class, UpdateGroup.class})
@Email(message = "邮箱格式不正确", groups = {AddGroup.class, UpdateGroup.class})
private String email;
/** 手机号 */
private String mobile;
/** 状态 0:禁用 1:正常 */
private Integer status;
/** 创建者ID */
private Long createUserId;
/** 创建时间 */
private Date createTime;
/** 角色ID列表 */
@TableField(exist = false)
private List<Long> roleIdList;
}
- 添加
@TableField(exist = false)
注解的字段,非存储字段。后续的实体,补充重复赘述。 - 每个字段比较简单,胖友自己根据注释理解下即可。
- renren-fast 的 DAO 采用 MyBatis-Plus 访问数据库。感兴趣的胖友,可以看看《芋道 Spring Boot MyBatis 入门》的「4. MyBatis-Plus」小节。
对应表的创建 SQL 如下:
-- 系统用户
CREATE TABLE `sys_user` (
`user_id` bigint NOT NULL AUTO_INCREMENT,
`username` varchar(50) NOT NULL COMMENT '用户名',
`password` varchar(100) COMMENT '密码',
`salt` varchar(20) COMMENT '盐',
`email` varchar(100) COMMENT '邮箱',
`mobile` varchar(100) COMMENT '手机号',
`status` tinyint COMMENT '状态 0:禁用 1:正常',
`create_user_id` bigint(20) COMMENT '创建者ID',
`create_time` datetime COMMENT '创建时间',
PRIMARY KEY (`user_id`),
UNIQUE INDEX (`username`)
) ENGINE=`InnoDB` DEFAULT CHARACTER SET utf8mb4 COMMENT='系统用户';
4.1.2 SysRoleEntity
SysRole ,角色实体类。代码如下:
// SysRoleEntity.java
@Data
@TableName("sys_role")
public class SysRoleEntity implements Serializable {
private static final long serialVersionUID = 1L;
/** 角色ID */
@TableId
private Long roleId;
@NotBlank(message = "角色名称不能为空")
private String roleName;
/** 备注 */
private String remark;
/** 创建者ID */
private Long createUserId;
/** 创建时间 */
private Date createTime;
@TableField(exist=false)
private List<Long> menuIdList;
}
- 每个字段比较简单,胖友自己根据注释理解下即可。
对应表的创建 SQL 如下:
CREATE TABLE `sys_role` (
`role_id` bigint NOT NULL AUTO_INCREMENT,
`role_name` varchar(100) COMMENT '角色名称',
`remark` varchar(100) COMMENT '备注',
`create_user_id` bigint(20) COMMENT '创建者ID',
`create_time` datetime COMMENT '创建时间',
PRIMARY KEY (`role_id`)
) ENGINE=`InnoDB` DEFAULT CHARACTER SET utf8mb4 COMMENT='角色';
4.1.3 SysUserRoleEntity
SysUserRoleEntity ,用户和角色关联实体类。代码如下:
// SysUserRoleEntity.java
public class SysUserRoleEntity {
/** 用户ID */
private Long userId;
/** 角色ID */
private Long roleId;
// ...省略 set/get 方法
}
- 每个字段比较简单,胖友自己根据注释理解下即可。
对应表的创建 SQL 如下:
-- 用户与角色对应关系
CREATE TABLE `sys_user_role` (
`id` bigint NOT NULL AUTO_INCREMENT,
`user_id` bigint COMMENT '用户ID',
`role_id` bigint COMMENT '角色ID',
PRIMARY KEY (`id`)
) ENGINE=`InnoDB` DEFAULT CHARACTER SET utf8mb4 COMMENT='用户与角色对应关系';
4.1.4 SysMenuEntity
SysMenuEntity ,菜单权限实体类。代码如下:
// SysMenuEntity.java
@Data
@TableName("sys_menu")
public class SysMenuEntity implements Serializable {
private static final long serialVersionUID = 1L;
/** 菜单ID */
@TableId
private Long menuId;
/** 父菜单ID,一级菜单为0 */
private Long parentId;
/** 父菜单名称 */
@TableField(exist = false)
private String parentName;
/** 菜单名称 */
private String name;
/** 菜单URL */
private String url;
/** 授权(多个用逗号分隔,如:user:list,user:create) */
private String perms;
/** 类型 0:目录 1:菜单 2:按钮 */
private Integer type;
/** 菜单图标 */
private String icon;
/** 排序 */
private Integer orderNum;
/** ztree 属性 */
@TableField(exist = false)
private Boolean open;
@TableField(exist = false)
private List<?> list;
}
😈 个人感觉,这个实体改成 SysResourceEntity 资源,更加合适,菜单仅仅是其中的一种。
每个字段比较简单,胖友自己根据资源理解下即可。我们来重点看几个字段。
type
属性,定义了三种类型。其中,2
代表按钮,是为了做页面中的功能级的权限。perms
属性,对应的权限标识 字符串。一般格式为${大模块}:${小模块}:{操作}
。示例如下:
用户查询:system:user:query
用户新增:system:user:add
用户修改:system:user:edit
用户删除:system:user:remove
用户导出:system:user:export
用户导入:system:user:import
重置密码:system:user:resetPwd
- 对于前端来说,每个按钮在展示时,可以判断用户是否有该按钮的权限。如果没有,则进行隐藏。当然,前端在首次进入系统的时候,会请求一次权限列表到本地进行缓存。
- 对于后端来说,每个接口上会添加 Shiro
@RequiresPermissions("system:user:query")
注解。在请求接口时,会校验用户是否有该 URL 对应的权限。如果没有,则会抛出权限验证失败的异常。 - 一个
perms
属性,可以对应多个权限标识 ,使用逗号分隔。例如说:"system:user:query,system:user:add"
。
对应表的创建 SQL 如下:
-- 菜单
CREATE TABLE `sys_menu` (
`menu_id` bigint NOT NULL AUTO_INCREMENT,
`parent_id` bigint COMMENT '父菜单ID,一级菜单为0',
`name` varchar(50) COMMENT '菜单名称',
`url` varchar(200) COMMENT '菜单URL',
`perms` varchar(500) COMMENT '授权(多个用逗号分隔,如:user:list,user:create)',
`type` int COMMENT '类型 0:目录 1:菜单 2:按钮',
`icon` varchar(50) COMMENT '菜单图标',
`order_num` int COMMENT '排序',
PRIMARY KEY (`menu_id`)
) ENGINE=`InnoDB` DEFAULT CHARACTER SET utf8mb4 COMMENT='菜单管理';
4.1.5 SysRoleMenuEntity
SysRoleMenuEntity ,角色和菜单关联实体类。代码如下:
// SysRoleMenu.java
@Data
@TableName("sys_role_menu")
public class SysRoleMenuEntity implements Serializable {
private static final long serialVersionUID = 1L;
@TableId
private Long id;
/** 角色ID */
private Long roleId;
/** 菜单ID */
private Long menuId;
}
对应表的创建 SQL 如下:
-- 角色与菜单对应关系
CREATE TABLE `sys_role_menu` (
`id` bigint NOT NULL AUTO_INCREMENT,
`role_id` bigint COMMENT '角色ID',
`menu_id` bigint COMMENT '菜单ID',
PRIMARY KEY (`id`)
) ENGINE=`InnoDB` DEFAULT CHARACTER SET utf8mb4 COMMENT='角色与菜单对应关系';
4.1.6 SysUserTokenEntity
SysUserTokenEntity ,用户 Token 实体类。代码如下:
// SysUserTokenEntity.java
@Data
@TableName("sys_user_token")
public class SysUserTokenEntity implements Serializable {
private static final long serialVersionUID = 1L;
//用户ID
@TableId(type = IdType.INPUT)
private Long userId;
//token
private String token;
//过期时间
private Date expireTime;
//更新时间
private Date updateTime;
}
- 每个字段比较简单,胖友自己根据注释理解下即可。
- 用户使用
username
和password
登陆成功后,会生成 SysUserTokenEntity 记录到数据库中。后续的请求,使用SysUserTokenEntity.token
作为身份标识。
对应表的创建 SQL 如下:
-- 系统用户Token
CREATE TABLE `sys_user_token` (
`user_id` bigint(20) NOT NULL,
`token` varchar(100) NOT NULL COMMENT 'token',
`expire_time` datetime DEFAULT NULL COMMENT '过期时间',
`update_time` datetime DEFAULT NULL COMMENT '更新时间',
PRIMARY KEY (`user_id`),
UNIQUE KEY `token` (`token`)
) ENGINE=`InnoDB` DEFAULT CHARACTER SET utf8mb4 COMMENT='系统用户Token';
4.2 ShiroConfig
在 ShiroConfig 配置类,实现 Shiro 的自定义配置。代码如下:
// ShiroConfig.java
@Configuration
public class ShiroConfig {
@Bean("securityManager")
public SecurityManager securityManager(OAuth2Realm oAuth2Realm) { /**省略代码**/ }
@Bean("shiroFilter")
public ShiroFilterFactoryBean shiroFilter(SecurityManager securityManager) { /**省略代码**/ }
@Bean("lifecycleBeanPostProcessor")
public LifecycleBeanPostProcessor lifecycleBeanPostProcessor() { /**省略代码**/ }
@Bean
public AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor(SecurityManager securityManager) { /**省略代码**/ }
}
- 一共有四个 Bean 的配置,我们逐个来看看。
4.2.1 Realm
在 renren-fast 中,自定义 AuthorizingRealm 的实现类 OAuth2Realm ,读取我们自定义的数据库表结构,提供认证和授权功能。
因为 OAuth2Realm 的类上,已经添加了 @Component
注解,所以就不需要在 ShiroConfig 中进行 Bean 的配置。
关于 OAuth2Realm 的代码详细解析,我们见「4.4 OAuth2Filter」和「4.5 权限验证」 。
4.2.2 SecurityManager
#securityManager()
方法,我们创建了 DefaultWebSecurityManager Bean 对象。代码如下:
// ShiroConfig.java
@Bean("securityManager")
public SecurityManager securityManager(OAuth2Realm oAuth2Realm) {
// 创建 DefaultWebSecurityManager 对象
DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager();
// 设置其使用的 Realm 为 OAuth2Realm
securityManager.setRealm(oAuth2Realm);
// 无需使用记住密码功能
securityManager.setRememberMeManager(null);
return securityManager;
}
- 和「2.2.2 SecurityManager」基本一致。
4.2.3 ShiroFilter
在 #shiroFilterFactoryBean()
方法,我们创建了 ShiroFilterFactoryBean Bean 对象。代码如下:
// ShiroConfig.java
@Bean("shiroFilter")
public ShiroFilterFactoryBean shiroFilter(SecurityManager securityManager) {
// 创建 ShiroFilterFactoryBean 对象,用于创建 ShiroFilter 过滤器
ShiroFilterFactoryBean shiroFilter = new ShiroFilterFactoryBean();
// 设置 SecurityManager
shiroFilter.setSecurityManager(securityManager);
// <1> 创建 OAuth2Filter 过滤器,并设置名字为 oauth2 。
Map<String, Filter> filters = new HashMap<>();
filters.put("oauth2", new OAuth2Filter());
shiroFilter.setFilters(filters);
// <2> ...
Map<String, String> filterMap = new LinkedHashMap<>();
filterMap.put("/webjars/**", "anon");
filterMap.put("/druid/**", "anon");
filterMap.put("/app/**", "anon");
filterMap.put("/sys/login", "anon"); // <3> 登陆接口
filterMap.put("/swagger/**", "anon");
filterMap.put("/v2/api-docs", "anon");
filterMap.put("/swagger-ui.html", "anon");
filterMap.put("/swagger-resources/**", "anon");
filterMap.put("/captcha.jpg", "anon");
filterMap.put("/aaa.txt", "anon");
filterMap.put("/**", "oauth2"); // <4> 默认剩余的 URL ,需要经过认证
shiroFilter.setFilterChainDefinitionMap(filterMap);
return shiroFilter;
}
- 和「2.2.3 ShiroFilter」略有差别,我们逐个来说说。
<1>
处,创建 OAuth2Filter 过滤器,并设置名字为"oauth2"
。该过滤器,用于对请求头带的 OAuth2 的 Token 进行认证。<2>
处,我们无需设置各种 URL 。因为在前后端分离之后,我们可以结合前端一起,实现自定义的登陆流程。当然,如果继续使用 Shiro 定义的登陆流程,实际也是没问题的。<3>
处,设置登陆接口/sys/login
允许匿名访问,不然咱没法实现登陆逻辑哈。详细解析,见「4.3 登陆 API 接口」。<4>
处,剩余的 URL ,我们设置为oauth2
,使用 OAuth2Filter 来基于请求头带的 OAuth2 的 Token 进行认证。如果认证不通过,则返回未认证的错误提示。详细解析,见「4.4 OAuth2Filter」。
下面,我们详细的来看看,各个配置的 Bean 的逻辑。
4.3 登陆 API 接口
SysLoginController#login(...)
在 SysLoginController 中,定义了 /login
接口,提供登陆功能。代码如下:
// SysLoginController.java
@Autowired
private SysUserService sysUserService;
@Autowired
private SysUserTokenService sysUserTokenService;
@Autowired
private SysCaptchaService sysCaptchaService;
@PostMapping("/sys/login")
public Map<String, Object> login(@RequestBody SysLoginForm form) {
// <1> 验证图片验证码的正确性
boolean captcha = sysCaptchaService.validate(form.getUuid(), form.getCaptcha());
if (!captcha) {
return R.error("验证码不正确");
}
// <2> 获得之地当用户名的 SysUserEntity
SysUserEntity user = sysUserService.queryByUserName(form.getUsername());
if (user == null || !user.getPassword().equals(new Sha256Hash(form.getPassword(), user.getSalt()).toHex())) { // 账号不存在、密码错误
return R.error("账号或密码不正确");
}
if (user.getStatus() == 0) { // 账号锁定
return R.error("账号已被锁定,请联系管理员");
}
// <3> 生成 Token ,并返回结果
return sysUserTokenService.createToken(user.getUserId());
}
<1>
处,验证图片验证码的正确性。该验证码会存储在 MySQL 数据库中,通过uuid
作为对应的标识。生成的逻辑,胖友自己看 SysLoginController 提供的/captcha.jpg
接口。<2>
处,调用 SysUserService 的#queryByUserName(String username)
方法,获得指定用户名的 SysUserEntity ,然后进行校验。详细解析,见「4.3.1 加载用户信息」。<3>
处,调用 SysUserTokenService 的#createToken(long userId)
方法,给认证通过的用户,生成其对应的认证 Token 。这样,该用户的后续请求,就使用该 Token 作为身份标识进行认证。
4.3.1 加载用户信息
在 SysUserServiceImpl 中,实现 SysUserService 接口定义的 #queryByUserName(String username)
方法,获得指定用户名的 SysUserEntity 。代码如下:
// SysUserServiceImpl.java
@Override
public SysUserEntity queryByUserName(String username) {
// baseMapper 由 MyBatis-Plus 提供
return baseMapper.queryByUserName(username);
}
// SysUserDao.XML
<select id="queryByUserName" resultType="io.renren.modules.sys.entity.SysUserEntity">
select * from sys_user where username = #{username}
</select>
- 通过查询
sys_user
表,将username
对应的 SysUser 查询出来。
4.3.2 创建认证 Token
在 SysUserTokenServiceImpl 中,实现 SysUserTokenService 接口定义的 #createToken(LoginUser loginUser)
方法,给认证通过的用户,生成其对应的认证 Token 。代码如下:
// SysUserTokenServiceImpl.java
// 12小时后过期
private final static int EXPIRE = 3600 * 12;
@Override
public R createToken(long userId) {
// <1> 生成一个 token
String token = TokenGenerator.generateValue();
// <2> 当前时间
Date now = new Date();
// <2> 过期时间
Date expireTime = new Date(now.getTime() + EXPIRE * 1000);
// <3> 判断是否生成过 token
SysUserTokenEntity tokenEntity = this.getById(userId);
if (tokenEntity == null) { // 新增 SysUserTokenEntity
tokenEntity = new SysUserTokenEntity();
tokenEntity.setUserId(userId);
tokenEntity.setToken(token);
tokenEntity.setUpdateTime(now);
tokenEntity.setExpireTime(expireTime);
// 保存 token
this.save(tokenEntity);
} else { // 更新 SysUserTokenEntity
tokenEntity.setToken(token);
tokenEntity.setUpdateTime(now);
tokenEntity.setExpireTime(expireTime);
// 更新 token
this.updateById(tokenEntity);
}
// <4> 返回 token 和过期时间
return R.ok().put("token", token).put("expire", EXPIRE);
}
<1>
处,调用 TokenGenerator 的#generateValue()
方法,生成一个 token 。其内部逻辑是生成 UUID 后,再进行一次 MD5 编码。感兴趣的胖友,自己去瞅瞅。<2>
处,获得当前时间,并计算 token 的过期时间为 12 小时后。<3>
处,根据该用户是否已经有存在的 SysUserTokenEntity ,进行插入或更新。在 renren-fast 项目中,一个 SysUserEntity 有且仅有一个对应的 SysUserTokenEntity 。如果胖友希望用户登陆后,老的 token 不要作废,则这里可以改成插入 SysUserTokenEntity 即可。<4>
处,返回token
和过期时间。
4.4 OAuth2Filter
在 OAuth2Filter 中,继承 Shiro AuthenticatingFilter 过滤器,实现了基于 Token 的认证。代码如下:
// OAuth2Filter.java
public class OAuth2Filter extends AuthenticatingFilter {
@Override
protected AuthenticationToken createToken(ServletRequest request, ServletResponse response) { /**省略代码**/ }
@Override
protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) { /**省略代码**/ }
@Override
protected boolean onAccessDenied(ServletRequest request, ServletResponse response) throws Exception { /**省略代码**/ }
@Override
protected boolean onLoginFailure(AuthenticationToken token, AuthenticationException e, ServletRequest request, ServletResponse response) { /**省略代码**/ }
}
- 通过继承 Shiro AuthenticatingFilter 过滤器,可以简化实现整个认证过程的代码。FormAuthenticationFilter 和 BasicHttpAuthenticationFilter 就是继承自 AuthenticatingFilter 。
下面,我们逐个看看 OAuth2Filter 的每一个方法的实现。
4.4.1 isAccessAllowed
#isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue)
方法,判断是否允许访问。代码如下:
// OAuth2Filter.java
@Override
protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) {
return ((HttpServletRequest) request).getMethod().equals(RequestMethod.OPTIONS.name());
}
- 在这里,只允许
OPTIONS
类型的请求可以直接允许访问。 - 在返回
false
时,就可以进入「4.4.3 onAccessDenied」的流程,根据请求带的 Token 进行认证。如果认证通过,说明可以访问。
4.4.2 createToken
#createToken(ServletRequest request, ServletResponse response)
方法,创建认真使用的 AuthenticationToken 。代码如下:
// OAuth2Filter.java
@Override
protected AuthenticationToken createToken(ServletRequest request, ServletResponse response) {
// <1> 获取请求中的 token
String token = getRequestToken((HttpServletRequest) request);
// 如果不存在,则返回 null
if (StringUtils.isBlank(token)) {
return null;
}
// <2> 创建 OAuth2Token 对象
return new OAuth2Token(token);
}
<1>
处,调用#getRequestToken(HttpServletRequest httpRequest)
方法,获得请求中的token
。代码如下:
// OAuth2Filter.java
private String getRequestToken(HttpServletRequest httpRequest) {
// 优先,从 header 中获取 token
String token = httpRequest.getHeader("token");
// 次之,如果 header 中不存在 token ,则从参数中获取 token
if (StringUtils.isBlank(token)) {
token = httpRequest.getParameter("token");
}
return token;
}
<2>
处,创建自定义的 OAuth2Token 。
4.4.3 onAccessDenied
#onAccessDenied(ServletRequest request, ServletResponse response)
方法,根据请求带的 Token 进行认证。如果认证通过,说明可以访问。代码如下:
// OAuth2Filter.java
@Override
protected boolean onAccessDenied(ServletRequest request, ServletResponse response) throws Exception {
// <1> 获取请求中的 token 。如果 token 不存在,直接返回 401 ,认证不通过
String token = getRequestToken((HttpServletRequest) request);
if (StringUtils.isBlank(token)) {
// 设置响应 Header
HttpServletResponse httpResponse = (HttpServletResponse) response;
httpResponse.setHeader("Access-Control-Allow-Credentials", "true");
httpResponse.setHeader("Access-Control-Allow-Origin", HttpContextUtils.getOrigin());
// 返回认证不通过
String json = new Gson().toJson(R.error(HttpStatus.SC_UNAUTHORIZED, "invalid token"));
httpResponse.getWriter().print(json);
// 返回 false
return false;
}
// <2> 执行登陆逻辑,实际执行的是基于 Token 进行认证。
return executeLogin(request, response);
}
<1>
处,获取请求中的token
。如果token
不存在,直接返回 401 ,认证不通过的 JSON 提示。<2>
处,调用父类 AuthenticatingFilter 的#executeLogin(request, response)
方法,执行登陆逻辑。实际上在方法内部,调用 OAuth2Realm 的#doGetAuthenticationInfo(AuthenticationToken token)
方法,执行基于 Token 进行认证。代码如下:
// OAuth2Realm.java
@Autowired
private ShiroService shiroService;
@Override
protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException {
String accessToken = (String) token.getPrincipal();
// <1> 根据 accessToken ,查询用户信息
SysUserTokenEntity tokenEntity = shiroService.queryByToken(accessToken);
// token 失效
if (tokenEntity == null || tokenEntity.getExpireTime().getTime() < System.currentTimeMillis()) {
throw new IncorrectCredentialsException("token失效,请重新登录");
}
// <2> 查询用户信息
SysUserEntity user = shiroService.queryUser(tokenEntity.getUserId());
// 账号锁定
if (user.getStatus() == 0) {
throw new LockedAccountException("账号已被锁定,请联系管理员");
}
// <3> 创建 SimpleAuthenticationInfo 对象
return new SimpleAuthenticationInfo(user, accessToken, getName());
}
<1>
处,调用 ShiroService 的#queryByToken(String token)
方法,查询 Token (在 OAuth2 中,Token 为访问令牌accessToken
)对应的 SysUserTokenEntity 。如果不存在或者已过期,抛出 IncorrectCredentialsException 异常。代码如下:
// ShiroServiceImpl.java
@Autowired
private SysUserTokenDao sysUserTokenDao;
@Override
public SysUserTokenEntity queryByToken(String token) {
return sysUserTokenDao.queryByToken(token);
}
// SysUserTokenDao.XML
<select id="queryByToken" resultType="io.renren.modules.sys.entity.SysUserTokenEntity">
select * from sys_user_token where token = #{value}
</select>
- <2>处,调用 ShiroService 的 `#queryUser(Long userId)` 方法,查询用户编号对应的 SysUserEntity 。如果已禁用,抛出 LockedAccountException 异常。代码如下:
// ShiroServiceImpl.java
@Autowired
private SysUserDao sysUserDao;
@Override
public SysUserEntity queryUser(Long userId) {
return sysUserDao.selectById(userId);
}
<3>` 处,创建 Shiro [SimpleAuthenticationInfo]
(https://github.com/apache/shiro/blob/master/core/src/main/java/org/apache/shiro/authc/SimpleAuthenticationInfo.java) 对象,为当前用户的认证
信息。
至此,我们完成了基于 Token 进行认证的代码,胖友可以自己在理一理,顺一瞬。
4.4.4 onLoginFailure
#onLoginFailure(ServletRequest request, ServletResponse response)
方法,处理「4.4.3 onAccessDenied」 中,认证失败的时候,返回 401 ,认证不通过的 JSON 提示。代码如下:
// OAuth2Filter.java
@Override
protected boolean onLoginFailure(AuthenticationToken token, AuthenticationException e, ServletRequest request, ServletResponse response) {
// 设置响应 Header
HttpServletResponse httpResponse = (HttpServletResponse) response;
httpResponse.setContentType("application/json;charset=utf-8");
httpResponse.setHeader("Access-Control-Allow-Credentials", "true");
httpResponse.setHeader("Access-Control-Allow-Origin", HttpContextUtils.getOrigin());
try {
// 处理登录失败的异常
Throwable throwable = e.getCause() == null ? e : e.getCause();
R r = R.error(HttpStatus.SC_UNAUTHORIZED, throwable.getMessage());
// 返回认证不通过
String json = new Gson().toJson(r);
httpResponse.getWriter().print(json);
} catch (IOException ignored) {
}
// 返回 false
return false;
}
- 代码实现上,和「4.4.3 onAccessDenied」的请求 Token 不存在时的逻辑是一样的。
4.5 权限验证
在 renren-fast 中,使用「3. Shiro 注解」,实现每个 URL 的自定义权限。例如:
// SysConfigController.java
@GetMapping("/list")
@RequiresPermissions("sys:config:list")
public R list(@RequestParam Map<String, Object> params) { /**省略代码**/ }
因为要验证权限,所以会调用到 OAuth2Realm 的 #doGetAuthorizationInfo(PrincipalCollection principals)
方法,进行鉴权,获得用户拥有的权限。代码如下:
// OAuth2Realm.java
@Autowired
private ShiroService shiroService;
@Override
protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) {
// <1> 获得 SysUserEntity 对象
SysUserEntity user = (SysUserEntity) principals.getPrimaryPrincipal();
Long userId = user.getUserId();
// <2> 用户权限列表
Set<String> permsSet = shiroService.getUserPermissions(userId);
// <3> 创建 SimpleAuthorizationInfo 对象
SimpleAuthorizationInfo info = new SimpleAuthorizationInfo();
info.setStringPermissions(permsSet);
return info;
}
<1>
处,获得 SysUserEntity 对象。该对象就是我们在 OAuth2Realm 的#doGetAuthenticationInfo(AuthenticationToken token)
方法中,所认证获得的。<2>
处,调用 ShiroService 的getUserPermissions(long userId)
方法,获得该用户拥有的权限数组。代码如下:
// OAuth2Realm.java
@Autowired
private SysMenuDao sysMenuDao;
@Autowired
private SysUserDao sysUserDao;
@Override
public Set<String> getUserPermissions(long userId) {
List<String> permsList;
// <1.1> 系统管理员,拥有最高权限
if (userId == Constant.SUPER_ADMIN) {
// 如果是管理员,则查询所有 SysMenuEntity 数组
List<SysMenuEntity> menuList = sysMenuDao.selectList(null);
permsList = new ArrayList<>(menuList.size());
for (SysMenuEntity menu : menuList) {
permsList.add(menu.getPerms());
}
// <1.2>
} else {
// 如果是普通用户,则查询其拥有的 SysMenuEntity 数组
permsList = sysUserDao.queryAllPerms(userId);
}
// <2> 用户权限列表
Set<String> permsSet = new HashSet<>();
for (String perms : permsList) {
if (StringUtils.isBlank(perms)) {
continue;
}
// 使用逗号分隔,每一个 perms
permsSet.addAll(Arrays.asList(perms.trim().split(",")));
}
return permsSet;
}
<1.1>
处,如果是管理员(id == Constant.SUPER_ADMIN == 1
)时,调用 SysMenuDao 的#selectList(Wrapper<T> queryWrapper)
方法,查询所有 SysMenuEntity 数组,从而实现管理员拥有全部权限。<1.2>
处,如果是普通用户,调用 SysMenuDao 的#queryAllPerms(Long userId)
方法,查询该用户拥有的 SysMenuEntity 数组。代码如下:
// SysMenuDao.java
<select id="queryAllPerms" resultType="string">
select m.perms from sys_user_role ur
LEFT JOIN sys_role_menu rm on ur.role_id = rm.role_id
LEFT JOIN sys_menu m on rm.menu_id = m.menu_id
where ur.user_id = #{userId}
</select>
- 通过
sys_user_role
连接sys_role_menu
和sys_menu
表,实现查询用户的所有权限。
- 通过
<2>
处,返回用户权限列表。因为一个SysMenuEntity.perms
可能对应多个权限,使用逗号分隔,所以这里需要做处理。
<3>
处,创建 Shiro SimpleAuthorizationInfo 对象,为当前用户的授权信息。另外,如果胖友想要使用 Shiro 的
@RequiresRoles
注解,需要读取用户拥有的角色。因为 renren-fast 目前暂时未使用该注解,所以并没有实现该逻辑。
4.6 获得权限 API 接口
在 SysMenuController 中,定义了 /sys/menu/nav
接口,获得当前登陆用户的菜单 和权限。代码如下:
// SysMenuController.java
@Autowired
private SysMenuService sysMenuService;
@Autowired
private ShiroService shiroService;
/**
* 导航菜单
*/
@GetMapping("/nav")
public R nav() {
// <1> 获得用户的菜单数组
List<SysMenuEntity> menuList = sysMenuService.getUserMenuList(getUserId());
// <2> 获得用户的权限集合
Set<String> permissions = shiroService.getUserPermissions(getUserId());
// <3> 返回
return R.ok().put("menuList", menuList)
.put("permissions", permissions);
}
<1>
处,调用 SysMenuService 的#getUserMenuList(Long userId)
方法,获得用户的菜单数组。代码如下:
// SysMenuServiceImpl.java
@Autowired
private SysUserService sysUserService;
@Override
public List<SysMenuEntity> getUserMenuList(Long userId) {
// 系统管理员,拥有最高权限
if (userId == Constant.SUPER_ADMIN) {
return getAllMenuList(null);
}
// 用户菜单列表
List<Long> menuIdList = sysUserService.queryAllMenuId(userId);
return getAllMenuList(menuIdList);
}
/**
* 获取所有菜单列表
*/
private List<SysMenuEntity> getAllMenuList(List<Long> menuIdList) {
// 查询根菜单列表
List<SysMenuEntity> menuList = queryListParentId(0L, menuIdList);
// 递归获取子菜单
getMenuTreeList(menuList, menuIdList);
return menuList;
}
/**
* 递归
*/
private List<SysMenuEntity> getMenuTreeList(List<SysMenuEntity> menuList, List<Long> menuIdList) {
List<SysMenuEntity> subMenuList = new ArrayList<SysMenuEntity>();
for (SysMenuEntity entity : menuList) {
// 目录
if (entity.getType() == Constant.MenuType.CATALOG.getValue()) {
entity.setList(getMenuTreeList(queryListParentId(entity.getMenuId(), menuIdList), menuIdList)); // <X>
}
subMenuList.add(entity);
}
return subMenuList;
}
- 这块代码写的比较糟糕,在
<X>
处存在递归查询,在菜单量大的时候,会导致性能较差。可以考虑将用户拥有的菜单一次性查询出来,然后在内存中拼接树形结构。
- 这块代码写的比较糟糕,在
<2>
处,调用 ShiroService 的getUserPermissions(long userId)
方法,获得该用户拥有的权限数组。<3>
处,返回用户拥有的菜单和权限。
4.7 退出 API 接口
在 SysLoginController 中,定义了 /logout
接口,提供退出功能。代码如下:
// SysLoginController.java
@Autowired
private SysUserTokenService sysUserTokenService;
@PostMapping("/sys/logout")
public R logout() {
sysUserTokenService.logout(getUserId());
return R.ok();
}
- 调用 SysUserTokenServiceImpl 的
#logout(long userId)
方法,实现用户的退出。代码如下:
// SysUserTokenServiceImpl.java
@Override
public void logout(long userId) {
// 生成一个token
String token = TokenGenerator.generateValue();
// 修改token
SysUserTokenEntity tokenEntity = new SysUserTokenEntity();
tokenEntity.setUserId(userId);
tokenEntity.setToken(token);
this.updateById(tokenEntity);
}
- 通过创建一个新的
token
值,修改该用户的 SysUserTokenEntity ,从而使用户当前的 Token 失效。 - 😈 有点尴尬的实现~胖友可以给 SysUserTokenEntity 增加一个标记删除的字段,或者修改过期时间。
- 通过创建一个新的
4.8 权限管理
如下的 Controller ,提供了 renren-fast 的权限管理功能,比较简单,胖友自己去瞅瞅即可。
- 用户管理 SysUserController :用户是系统操作者,该功能主要完成系统用户配置。
- 角色管理 SysRoleController :角色菜单权限分配、设置角色按机构进行数据范围权限划分。
- 菜单管理 SysMenuController :配置系统菜单,操作权限,按钮权限标识等。
4.9 小小的建议
至此,我们完成了对 renren-fast 权限相关功能的源码进行解读,希望对胖友有一定的胖友。如果胖友项目中需要权限相关的功能,建议不要直接拷贝 renren-fast 的代码,而是按照自己的理解,一点点"重新"实现一遍。在这个过程中,我们会有更加深刻的理解,甚至会有自己的一些小创新。
另外,RuoYi 也是一个基于 Shiro 实现权限管理的开源项目。胖友也可以去借鉴学习下。
这里额外在推荐一些 Shiro 不错的内容:
- 《Shiro 实现原理与源码解析系统 ------ 精品合集》
- 《如何设计权限管理模块(附表结构)?》
- 《Spring Boot + Vue + Shiro 实现前后端分离、权限控制》
- 《学习如何使用 Shiro,从架构谈起,到框架集成!》
- 《SpringBoot + Shiro + Redis 共享 Session 实例》
- 《SpringBoot 整合 Shiro 实现动态权限加载更新+ Session 共享 + 单点登录》
不过艿艿实际项目中,并未采用 Spring Security 或是 Shiro 安全框架,而是自己团队开发了一个相对轻量级的组件。主要考虑,目前前后端分离之后,Shiro 内置的很多功能,已经不太需要,在加上拓展一些功能不是非常方便,有点"曲折",所以才选择自己开发。
来源:https://blog.csdn.net/weixin_42073629/article/details/105916553