一、前言
在所有的开发的系统中,都必须做认证(authentication)和授权(authorization),以保证系统的安全性。考虑到很多读者对认证和授权有点分不清楚。
authentication [ɔ,θɛntɪ'keʃən] 认证
authorization [,ɔθərɪ'zeʃən] 授权
1.1 以坐飞机举例子:
- 【认证】你要登机,你需要出示你的 passport 和 ticket,passport 是为了证明你张三确实是你张三,这就是 authentication。
- 【授权】而机票是为了证明你张三确实买了票可以上飞机,这就是 authorization。
1.2 以论坛举例子:
- 【认证】你要登录论坛,输入用户名张三,密码 1234,密码正确,证明你张三确实是张三,这就是 authentication。
- 【授权】再一 check 用户张三是个版主,所以有权限加精删别人帖,这就是 authorization 。
所以简单来说:认证解决“你是谁”的问题,授权解决“你能做什么”的问题。另外,在推荐阅读下《认证、授权、鉴权和权限控制》 文章,更加详细明确。
在 Java 生态中,目前有 Spring Security 和 Apache Shiro 两个安全框架,可以完成认证和授权的功能。本文,我们再来学习下 Apache Shiro 。其官方对自己介绍如下:
Apache Shiro™ 是一个功能强大且易于使用的 Java 安全框架,它可以提供身份验证、授权、加密和会话管理的功能。
通过 Shiro 易于理解的 API ,你可以快速、轻松地保护任何应用程序 —— 从最小的移动端应用程序到大型的的 Web 和企业级应用程序。
二、 快速入门
2.1 引入依赖
<?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.3.0.RELEASE</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<modelVersion>4.0.0</modelVersion>
<artifactId>Apache-shiro</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.5.3</version>
</dependency>
</dependencies>
</project>
shiro-spring-boot-starter
依赖对 Shiro 的自动化配置基本没啥用,需要下面的这个类ShiroConfig
自己来主动实现对 Shiro 的配置。
2.2 ShiroConfig
实现 Shiro 的自定义配置。代码如下:
package com.erbadagang.springboot.shiro.config;
import org.apache.shiro.realm.Realm;
import org.apache.shiro.realm.SimpleAccountRealm;
import org.apache.shiro.spring.web.ShiroFilterFactoryBean;
import org.apache.shiro.web.mgt.DefaultWebSecurityManager;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.util.LinkedHashMap;
import java.util.Map;
@Configuration
public class ShiroConfig {
@Bean
public Realm realm() {
// 创建 SimpleAccountRealm 对象
SimpleAccountRealm realm = new SimpleAccountRealm();
// 添加两个用户。参数分别是 username、password、roles 。
realm.addAccount("admin", "admin", "ADMIN");
realm.addAccount("normal", "normal", "NORMAL");
return realm;
}
@Bean
public DefaultWebSecurityManager securityManager() {
// 创建 DefaultWebSecurityManager 对象
DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager();
// 设置其使用的 Realm
securityManager.setRealm(this.realm());
return securityManager;
}
@Bean
public ShiroFilterFactoryBean shiroFilterFactoryBean() {
// 创建 ShiroFilterFactoryBean 对象,用于创建 ShiroFilter 过滤器
ShiroFilterFactoryBean filterFactoryBean = new ShiroFilterFactoryBean();
// 设置 SecurityManager
filterFactoryBean.setSecurityManager(this.securityManager());
// 设置 URL 们
filterFactoryBean.setLoginUrl("/login"); // 登陆 URL
filterFactoryBean.setSuccessUrl("/login_success"); // 登陆成功 URL
filterFactoryBean.setUnauthorizedUrl("/unauthorized"); // 无权限 URL
// 设置 URL 的权限配置
filterFactoryBean.setFilterChainDefinitionMap(this.filterChainDefinitionMap());
return filterFactoryBean;
}
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;
}
}
一共有三个 Bean 的配置,我们逐个来看看。
2.2.1 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 对象。代码如上所示:
- SimpleAccountRealm 是使用内存作为数据源,我们可以手动往里面添加用户、角色、权限等数据。毕竟作为一个示例,不想引入数据库,增加复杂性。不过我们在后续文章中,我们会看到我们使用自定义的 AuthorizingRealm 实现类。
- 在该方法里,我们添加了「admin/admin」和「normal/normal」两个用户,分别对应 ADMIN 和 NORMAL 角色。
2.2.2 SecurityManager
我们再来看看 SecurityManager 的定义,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 的权限配置。代码如下:
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.3 SecurityController
提供登录、登录成功等接口。代码如下:
package com.erbadagang.springboot.shiro.controller;
import org.apache.shiro.SecurityUtils;
import org.apache.shiro.authc.ExpiredCredentialsException;
import org.apache.shiro.authc.IncorrectCredentialsException;
import org.apache.shiro.authc.LockedAccountException;
import org.apache.shiro.authc.UnknownAccountException;
import org.apache.shiro.subject.Subject;
import org.apache.shiro.web.filter.authc.FormAuthenticationFilter;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseBody;
import javax.servlet.http.HttpServletRequest;
@Controller
@RequestMapping("/")
public class SecurityController {
private Logger logger = LoggerFactory.getLogger(getClass());
@GetMapping("/login")
public String loginPage() {
return "login.html";
}
@ResponseBody
@PostMapping("/login")
public String login(HttpServletRequest request) {
// 判断是否已经登陆
Subject subject = SecurityUtils.getSubject();
if (subject.getPrincipal() != null) {
return "你已经登陆账号:" + subject.getPrincipal();
}
// 获得登陆失败的原因
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;
}
@ResponseBody
@GetMapping("/login_success")
public String loginSuccess() {
return "登陆成功";
}
@ResponseBody
@GetMapping("/unauthorized")
public String unauthorized() {
return "你没有权限";
}
}
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
在 [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 的入门。可以自己多多测试一下。
三、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
注解,实现全局异常的处理。不了解的胖友,可以看看另外一篇文章的全局异常处理小节。
底线
本文源代码使用 Apache License 2.0开源许可协议,这里是本文源码Gitee地址,可通过命令git clone+地址
下载代码到本地,也可直接点击链接通过浏览器方式查看源代码。