在 SpringBoot2 中使用 Apache Shiro 实现登录身份验证和访问权限控制

作者:DeppWang原文地址

一、Shiro 登陆架构

下面是 Shiro 的用户登陆架构图,我们根据箭头来看一下流程。

image.png

1、Token:使用用户的登录信息创建令牌

UsernamePasswordToken token = new UsernamePasswordToken(username, password, true);

我们要先通过用户名和密码,生成一个 token,token 是一个用户令牌,用于在登陆的时候,Shiro 来验证用户是否有合法的身份。

2、Subject:执行登陆动作 (login)

Subject subject = SecurityUtils.getSubject(); // 获取 Subject 单例对象
subject.login(token); // 登陆

再通过 Subject 来执行登陆操作,将 token 发送给 Security Manager,让他来验证这个 token。Subject 中文翻译是主题。你可以理解为它是一个用户,是 User 的抽象概念。

3、Realm:自定义代码实现登陆身份验证和访问权限控制

先来看看 Realm,你从上图可以看出,Realm 在 Shiro 方框的外面。图片很形象,因为这一部分恰恰是需要我们自己去实现的。需要我们来设计如何验证登录用户的身份 (role),和这个用户是否具有访问某个 URL 的权限 (permission)。前者使用 AuthenticationInfo(验证) 实现,后者使用 AuthorizationInfo(授权) 实现。

4、Security Manager:Shiro 架构的核心

Security Manager,是 Shiro 架构的核心,简单来说,它根据我们自定义的 Realm,去完成验证和授权工作


如果这部分没有看懂,建议先根据下面的 “与 SpringBoot2 集成” 部分,搭建一个 demo,在项目中直观体验一下了,再回来看。

二、与 SpringBoot2 集成

注:以下内容是根据 “纯洁的微笑” 大神的《springboot 整合 shiro - 登录认证和权限管理》一文,将其中 SpringBoot1.5 升级到 2.1,针对 2.1 做了相应修改,同时针对一些知识点延伸学习。博文地址:http://www.ityouknow.com/springboot/2017/06/26/springboot-shiro.html

建议你手动搭建一个 demo,这样能更深入了解。

pom 依赖

<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<!--web 核心 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!--thymeleaf 模板 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
<!--HTML 扫描器和标签补偿器,补充 thymeleaf 对 html 的严格检验 -->
<dependency>
<groupId>net.sourceforge.nekohtml</groupId>
<artifactId>nekohtml</artifactId>
<version>1.9.22</version>
</dependency>
<!--Apache Shiro-->
<dependency>
<groupId>org.apache.shiro</groupId>
<artifactId>shiro-spring</artifactId>
<version>1.4.0</version>
</dependency>
<!-- 使用 Spring Data JPA 和 Hibernate-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<!-- 用于 MySQL 的 JDBC Type 4 驱动程序 -->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<scope>runtime</scope>
</dependency>
<!-- 热部署 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-devtools</artifactId>
<optional>true</optional>
</dependency>
<!-- 可使用注解自动生成 getter、setter 等方法 -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.4</version>
</dependency>
</dependencies>

为使用热部署,配置 <build></build>

<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<fork>true</fork>
</configuration>
</plugin>
</plugins>
</build>

配置文件

spring:
datasource:
url: jdbc:mysql://localhost:3306/springboot_shiro?useUnicode=true&characterEncoding=utf-8&serverTimezone=UTC
username: root
password: 12345678
driver-class-name: com.mysql.cj.jdbc.Driver

thymeleaf:
cache: false # 禁用模板引擎编译的缓存结果。由热部署来实现,更改代码后,使用 Ctrl+F9(IDEA) 更新
mode: LEGACYHTML5 # 避免 thymeleaf 对 html 文件的严格校验(如检查标签必须对称等)

# 使用 jpa 技术,运行实体代码自动生成数据表
jpa:
database: mysql
show-sql: true
hibernate:
ddl-auto: update
properties:
hibernate:
dialect: org.hibernate.dialect.MySQL5Dialect

server:
port: 9090

数据库设计

使用基于角色的访问控制 (Role-Based Access Control)—RBAC 来实现数据库设计,用户依赖角色,角色依赖权限。这样设计结构清晰,管理方便。建立三张表:user_info,sys_role,sys_permission。使用 sys_user_role 关联用户和角色,使用 sys_role_permission 关联角色和权限,不使用外键。

使用 jpa 技术,运行实体代码自动生成数据表。

用户信息实体。@Getter、@Setter 注解用于提供读写属性。因为有 getCredentialsSalt(),所以不使用 @Data 注解。

@Entity
@Getter
@Setter
public class UserInfo implements Serializable {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)//GenerationType.IDENTITY 避免生成 hibernate_sequence 表
private Integer uid;
@Column(unique = true)
private String username;// 帐号
private String name;// 名称(昵称或者真实姓名,不同系统不同定义)
private String password; // 密码;private String salt;// 加密密码的盐
private byte state;// 用户状态,0: 创建未认证(比如没有激活,没有输入验证码等等)-- 等待验证的用户,1: 正常状态,2:用户被锁定.

@ManyToMany(fetch = FetchType.EAGER)// 立即从数据库中进行加载数据;@JoinTable(name = "SysUserRole", joinColumns = {@JoinColumn(name = "uid")}, inverseJoinColumns = {@JoinColumn(name = "roleId")})
private List<SysRole> roleList;// 一个用户具有多个角色

/**
* 密码盐,重新对盐重新进行了定义,用户名 + salt,这样就更加不容易被破解
*
* @return
*/
public String getCredentialsSalt() {
return this.username + this.salt;
}
}

角色实体。使用 @Data 注解,为类提供读写属性,此外还提供了 equals()、hashCode()、toString() 方法。上面用户信息实体和角色实体会根据 @JoinTable 注解生成 sys_user_role 表。

@Entity
@Data
public class SysRole {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Integer id; // 编号
private String role; // 角色标识程序中判断使用,如 "admin", 这个是唯一的:
private String description; // 角色描述,UI 界面显示使用
private Boolean available = Boolean.FALSE; // 是否可用,如果不可用将不会添加给用户

// 用户 - 角色关系定义;@ManyToMany
@JoinTable(name = "SysUserRole", joinColumns = {@JoinColumn(name = "roleId")}, inverseJoinColumns = {@JoinColumn(name = "uid")})
private List<UserInfo> userInfos;// 一个角色对应多个用户

// 角色 -- 权限关系:多对多关系;@ManyToMany(fetch = FetchType.EAGER)
@JoinTable(name = "SysRolePermission", joinColumns = {@JoinColumn(name = "roleId")}, inverseJoinColumns = {@JoinColumn(name = "permissionId")})
private List<SysPermission> permissions;
}

权限实体。同理,角色实体和权限实体,通过 @JoinTable 注解生成 sys_role_permission 表

@Entity
@Data
public class SysPermission {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Integer id;// 主键.
private String name;// 名称.
@Column(columnDefinition = "enum('menu','button')")
private String resourceType;// 资源类型,[menu|button]
private String url;// 资源路径.
private String permission; // 权限字符串,menu 例子:role:*,button 例子:role:create,role:update,role:delete,role:view
private Long parentId; // 父编号
private String parentIds; // 父编号列表
private Boolean available = Boolean.FALSE;

@ManyToMany
@JoinTable(name = "SysRolePermission", joinColumns = {@JoinColumn(name = "permissionId")}, inverseJoinColumns = {@JoinColumn(name = "roleId")})
private List<SysRole> roles;
}

数据库数据:

INSERT INTO `user_info` (`uid`,`username`,`name`,`password`,`salt`,`state`) VALUES ('1', 'admin', ' 管理员 ', 'd3c59d25033dbf980d29554025c23a75', '8d78869f470951332959580424d4bf4f', 0);
INSERT INTO `sys_permission` (`id`,`available`,`name`,`parent_id`,`parent_ids`,`permission`,`resource_type`,`url`) VALUES (1,0,' 用户管理 ',0,'0/','userInfo:view','menu','userInfo/userList');
INSERT INTO `sys_permission` (`id`,`available`,`name`,`parent_id`,`parent_ids`,`permission`,`resource_type`,`url`) VALUES (2,0,' 用户添加 ',1,'0/1','userInfo:add','button','userInfo/userAdd');
INSERT INTO `sys_permission` (`id`,`available`,`name`,`parent_id`,`parent_ids`,`permission`,`resource_type`,`url`) VALUES (3,0,' 用户删除 ',1,'0/1','userInfo:del','button','userInfo/userDel');
INSERT INTO `sys_role` (`id`,`available`,`description`,`role`) VALUES (1,0,' 管理员 ','admin');
INSERT INTO `sys_role` (`id`,`available`,`description`,`role`) VALUES (2,0,'VIP 会员 ','vip');
INSERT INTO `sys_role` (`id`,`available`,`description`,`role`) VALUES (3,1,'test','test');
INSERT INTO `sys_role_permission` VALUES ('1', '1');
INSERT INTO `sys_role_permission` (`permission_id`,`role_id`) VALUES (2,1);
INSERT INTO `sys_role_permission` (`permission_id`,`role_id`) VALUES (3,2);
INSERT INTO `sys_user_role` (`role_id`,`uid`) VALUES (1,1);

三、配置 Shiro

Apache Shiro 核心通过 Filter 来实现,就好像 SpringMvc 通过 DispachServlet 去实现。

Filter 和 Interceptor 的区别:

Filter 是过滤器,Interceptor 是拦截器。前者基于回调函数实现,必须依靠容器支持。因为需要容器装配好整条 FilterChain 并逐个调用。后者基于代理实现,属于 AOP 的范畴。

在 Shrio 中实现登陆身份验证和访问权限控制有三种方式:

  1. 完全使用注解来实现登陆身份验证和访问权限控制
  2. 完全使用 URL 配置来实现登陆身份验证和访问权限控制
  3. 使用 URL 配置来实现登陆身份验证、使用注解来实现访问权限控制

第 3 种方式最灵活,所以用第三种。

1、使用 URL 配置来实现登陆身份验证

要实现当用户在浏览器地址访问项目 URL 时,Shiro 会拦截所有的请求,再根据配置的 ShrioFilter 过滤器来进行下一步操作。原理:Spring 容器会将所有的 Filter 交给 ShiroFilter 管理。

@Configuration
public class ShiroConfig {

@Bean
public ShiroFilterFactoryBean shiroFilter() {
ShiroFilterFactoryBean shiroFilterFactoryBean = new ShiroFilterFactoryBean();
shiroFilterFactoryBean.setSecurityManager(securityManager());
Map<String, String> filterChainDefinitionMap = new LinkedHashMap<String, String>();
// 以下过滤器按顺序判断
// 配置不会被拦截的链接,一般是排除前端文件(anon: 指定的 url 可以匿名访问)
// filterChainDefinitionMap.put("/static/**", "anon");
// 配置退出 过滤器,其中的具体的退出代码 Shiro 已经替我们实现了
filterChainDefinitionMap.put("/logout", "logout");
//authc: 所有 url 都必须认证通过才可以访问;filterChainDefinitionMap.put("/**", "authc");

// 当项目访问其他没有通过认证的 URL 时,会默认跳转到 /login,如果不设置默认会自动寻找 Web 工程根目录下的 "/login.jsp" 页面
shiroFilterFactoryBean.setLoginUrl("/login");
// 登录成功后要跳转的链接
shiroFilterFactoryBean.setSuccessUrl("/index");
// 当用户访问没有权限的 URL 时,跳转到未授权界面
shiroFilterFactoryBean.setUnauthorizedUrl("/403");
shiroFilterFactoryBean.setFilterChainDefinitionMap(filterChainDefinitionMap);
return shiroFilterFactoryBean;
}

@Bean
public SecurityManager securityManager() {
DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager();
securityManager.setRealm(myShiroRealm());
return securityManager;
}

@Bean
public MyShiroRealm myShiroRealm() {
MyShiroRealm myShiroRealm = new MyShiroRealm();
myShiroRealm.setCredentialsMatcher(hashedCredentialsMatcher());
}
}

authc 更深层次含义:指定 url 需要 form 表单登录,默认会从请求中获取 username、password 等参数并尝试登录,如果登录不了就会跳转到 loginUrl 配置的路径

在 Realm 中实现 AuthenticationInfo(登陆身份验证)

doGetAuthenticationInfo():用于验证 token 的 User 是否具有合法的身份,即检验账号密码是否正确,每次用户登录的时候都会调用。

public class MyShiroRealm extends AuthorizingRealm {
@Autowired
UserService userService;

@Override
protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authenticationToken) throws AuthenticationException {
// 获取登录的用户名
String username = (String) authenticationToken.getPrincipal();
// 根据用户名在数据库中查找此用户
// 实际项目中,这里可以根据实际情况做缓存,如果不做,Shiro 自己也是有时间间隔机制,2 分钟内不会重复执行该方法
UserInfo userInfo = userService.findByUsername(username);
if (userInfo == null) {
return null;
}
// 根据 salt 来验证 token 中的密码是否跟从数据库查找的密码匹配,匹配则登录成功。getName() 设置当前 Realm 的唯一名称,可自定义
return new SimpleAuthenticationInfo(
userInfo,
userInfo.getPassword(),
ByteSource.Util.bytes(userInfo.getCredentialsSalt()),// 盐
getName());
}
}

验证密码原理

先了解两个算法,散列算法与加密算法。

两者都是将一个 Object 变成一串无意义的字符串,不同点是经过散列的对象无法复原,是一个单向的过程。例如,对密码的加密通常就是使用散列算法,因此用户如果忘记密码只能通过修改而无法获取原始密码。但是对于信息的加密则是正规的加密算法,经过加密的信息是可以通过秘钥解密和还原。

在这里,我们将用户的密码使用散列算法 (MD5) 加密后保存到数据库。加密的时候就使用了 salt,salt 中文翻译是盐,你可以将他看成一个钥匙。

因为散列算法加密是单项的,不能还原。那我们如何来验证密码呢,这时候也需要使用 salt。我们将 token 中的明文密码,采用生成密文密码时一样的方式,通过 salt 再加密一次,对比两个加密后的密码。最后的验证是 SimpleAuthenticationInfo 去实现的。

如何散列加密

//newPassword(密文密码):d3c59d25033dbf980d29554025c23a75
String newPassword = new SimpleHash("MD5",// 散列算法:这里使用 MD5 算法
"123456",// 明文密码
ByteSource.Util.bytes("admin8d78869f470951332959580424d4bf4f"),//salt:用户名 + salt
2// 散列的次数,相当于 MD5(MD5(**))
).toHex();

// 生成一个 32 位数的 salt
byte[] saltByte = new byte[16];
SecureRandom random = new SecureRandom();
random.nextBytes(saltByte);
String salt = Hex.encodeToString((saltByte));

如何验证密码

配置 hashedCredentialsMatcher(凭证匹配器),让 SimpleAuthorizationInfo 知道如何验证密码:

@Bean
public HashedCredentialsMatcher hashedCredentialsMatcher() {
HashedCredentialsMatcher hashedCredentialsMatcher = new HashedCredentialsMatcher();
hashedCredentialsMatcher.setHashAlgorithmName("md5");// 散列算法
hashedCredentialsMatcher.setHashIterations(2);// 散列的次数
return hashedCredentialsMatcher;
}

2、使用注解来实现访问权限控制

@Controller
@RequestMapping("/userInfo")
public class UserInfoController {
/**
* 用户查询;* @return
*/
@RequestMapping("/userList")
@RequiresPermissions("userInfo:view")// 访问的权限
public String userList(){
return "userInfo";
}

/**
* 用户添加;* @return
*/
@RequestMapping("/userAdd")
@RequiresPermissions("userInfo:add")// 新增的权限
public String userAdd(){
return "userInfoAdd";
}

/**
* 用户删除;* @return
*/
@RequestMapping("/userDel")
@RequiresPermissions("userInfo:del")// 删除的权限
public String userDel(){
return "userInfoDel";
}
}

在 Relm 中实现 AuthorizationInfo(访问权限控制)

如果项目只需要 Apache Shiro 用于登陆验证,那么就不用使用 AuthorizationInfo,只需要返回一个 null。

protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principalCollection) {
return null;
}

doGetAuthorizationInfo():当用户访问带有 @RequiresPermissions 注解的 URL 时,会调用此方法验证是否有权限访问。

protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principalCollection) {
System.out.println("权限配置 -->MyShiroRealm.doGetAuthorizationInfo()");
UserInfo userInfo = (UserInfo) principalCollection.getPrimaryPrincipal();
SimpleAuthorizationInfo simpleAuthorizationInfo = new SimpleAuthorizationInfo();
// 获取当前用户的角色与权限,让 simpleAuthorizationInfo 去验证
for (SysRole sysRole : userInfo.getRoleList()) {
simpleAuthorizationInfo.addRole(sysRole.getRole());
for (SysPermission sysPermission : sysRole.getPermissions()) {
simpleAuthorizationInfo.addStringPermission(sysPermission.getPermission());
}
}
return simpleAuthorizationInfo;
}

代码开启注解

使用 Shrio 注解,需要在 ShrioConfig 中使用 AuthorizationAttributeSourceAdvisor 开启

@Bean
public AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor(SecurityManager securityManager) {
AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor = new AuthorizationAttributeSourceAdvisor();
authorizationAttributeSourceAdvisor.setSecurityManager(securityManager);
return authorizationAttributeSourceAdvisor;
}

自定义异常处理

当没有访问权限时,会抛出异常,需要自定义异常处理,将没有权限的异常重定向到 403 页面

@Bean
public SimpleMappingExceptionResolver
createSimpleMappingExceptionResolver() {
System.out.println("自定义异常处理");
SimpleMappingExceptionResolver resolver = new SimpleMappingExceptionResolver();
Properties mappings = new Properties();
mappings.setProperty("UnauthorizedException", "403");// 授权异常处理
resolver.setExceptionMappings(mappings); // None by default
resolver.setDefaultErrorView("error"); // No default
resolver.setExceptionAttribute("ex"); // Default is "exception"
return resolver;
}

同理,可以使用 @RequiresRoles("admin") 注解来验证角色 (身份)

在前端页面使用 Shiro 标签时也会触发权限控制,请看:https://www.jianshu.com/p/6786ddf54582

其他两种验证和授权请看:https://juejin.im/entry/5ad95ef26fb9a07a9f01185a

也可直接编代码测试:

Boolean isPermitted = SecurityUtils.getSubject().isPermitted("***");// 是否有什么权限
Boolean hasRole = SecurityUtils.getSubject().hasRole("***");// 是否有什么角色

登陆实现

前端登陆页面:

<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>Login</title>
</head>
<body>
错误信息:<h4 th:text="${msg}"></h4>
<form action="" method="post">
<p> 账号:<input type="text" name="username" value="admin"/></p>
<p> 密码:<input type="text" name="password" value="123456"/></p>
<p><input type="submit" value=" 登录 "/></p>
</form>
</body>
</html>

登陆接口:

@Controller
public class LoginController {

@RequestMapping("/login")
public String toLogin(HttpServletRequest request, Map<String, Object> map) {
System.out.println("HomeController.login()");
// 登录失败从 request 中获取 shiro 处理的异常信息。
// shiroLoginFailure: 就是 shiro 异常类的全类名.
String exception = (String) request.getAttribute("shiroLoginFailure");
System.out.println("exception=" + exception);
String msg = "";
if (exception != null) {
if (UnknownAccountException.class.getName().equals(exception)) {
System.out.println("UnknownAccountException -- > 账号不存在:");
msg = "UnknownAccountException -- > 账号不存在:";
} else if (IncorrectCredentialsException.class.getName().equals(exception)) {
System.out.println("IncorrectCredentialsException -- > 密码不正确:");
msg = "IncorrectCredentialsException -- > 密码不正确:";
} else if ("kaptchaValidateFailed".equals(exception)) {
System.out.println("kaptchaValidateFailed -- > 验证码错误 ");
msg = "kaptchaValidateFailed -- > 验证码错误 ";
} else {
msg = "else >> "+exception;
System.out.println("else -- >" + exception);
}
}
map.put("msg", msg);
return "login";
}

@RequestMapping({"/","/index"})
public String index(){
return "index";
}
}

你可能发现了,登陆没有用文章开头的 Subject 执行登陆动作,而是直接使用 action=”” 的表单登录。
为什么 action=”” 呢,这是因为设置了 "/**", "authc",当用户没有登陆时,所有 url(/**) 都会被重定向到 /login,而 action="" 或者 "/login" 将不被拦截,doGetAuthenticationInfo() 验证表单中的账号密码。

使用 Subject 执行登陆动作

那如何使用 Subject 执行登陆动作呢,需要使用 user 过滤器。当没有登录时,user 跟 authc 一样,会拦截所有的 url。

//user: 需要已登录或 “记住我” 的用户才能访问;filterChainDefinitionMap.put("/**", "user");

当使用 Post 请求的 /login 登陆时,将使用 Subject 执行登陆动作,doGetAuthenticationInfo() 方法验证

<form action="/login" method="post">
<p> 账号:<input type="text" name="username" value="admin"/></p>
<p> 密码:<input type="text" name="password" value="123456"/></p>
<p><input type="submit" value="登录"/></p>
</form>

@RequestMapping(value = "/login", method = RequestMethod.POST)
public String login(@RequestParam(value = "username") String userName,
@RequestParam(value = "password") String password) {
UsernamePasswordToken token = new UsernamePasswordToken(userName, password);
Subject subject = SecurityUtils.getSubject();
try {
subject.login(token);
} catch (AuthenticationException e) {
e.printStackTrace();
}
return "index";
}

项目源码

其余未贴出代码请查看项目源码springboot-shiro

总结

Shiro 的作用是拦截所有请求,判断用户是否登录,如果登录,给与「放行」,若没有登录,重定向到登录页面。

Shiro 判断登陆的方法:第一次登录,查 token 的密码是否跟数据库一致。登录后,服务端返回 cookie,以后每次登录利用 cookie 判断(可以通过登录后测试发现)。Shiro 内部维护了一个 SessionId 与 Session 的框架。

参考资料