什么是shiro
Apache Shiro 是 Java 的一个安全框架。目前,使用 Apache Shiro 的人越来越多,因为它相当简单,对比 Spring Security,可能没有 Spring Security 做的功能强大,但是在实际工作时可能并不需要那么复杂的东西,所以使用小而简单的 Shiro 就足够了。对于它俩到底哪个好,这个不必纠结,能更简单的解决项目问题就好了。
基本功能点
Authentication:身份认证 / 登录,验证用户是不是拥有相应的身份;
Authorization:授权,即权限验证,验证某个已认证的用户是否拥有某个权限;即判断用户是否能做事情,常见的如:验证某个用户是否拥有某个角色。或者细粒度的验证某个用户对某个资源是否具有某个权限;
Session Manager:会话管理,即用户登录后就是一次会话,在没有退出之前,它的所有信息都在会话中;会话可以是普通 JavaSE 环境的,也可以是如 Web 环境的;
Cryptography:加密,保护数据的安全性,如密码加密存储到数据库,而不是明文存储;
Web Support:Web 支持,可以非常容易的集成到 Web 环境;
Caching:缓存,比如用户登录后,其用户信息、拥有的角色 / 权限不必每次去查,这样可以提高效率;
Concurrency:shiro 支持多线程应用的并发验证,即如在一个线程中开启另一个线程,能把权限自动传播过去;
Testing:提供测试支持;
Run As:允许一个用户假装为另一个用户(如果他们允许)的身份进行访问;
Remember Me:记住我,这个是非常常见的功能,即一次登录后,下次再来的话不用登录了。
记住一点,Shiro 不会去维护用户、维护权限;这些需要我们自己去设计 / 提供;然后通过相应的接口注入给 Shiro 即可。
接下来我们分别从外部和内部来看看 Shiro 的架构,对于一个好的框架,从外部来看应该具有非常简单易于使用的 API,且 API 契约明确;从内部来看的话,其应该有一个可扩展的架构,即非常容易插入用户自定义实现,因为任何框架都不能满足所有需求。
首先,我们从外部来看 Shiro 吧,即从应用程序角度的来观察如何使用 Shiro 完成工作。如下图:
可以看到:应用代码直接交互的对象是 Subject,也就是说 Shiro 的对外 API 核心就是 Subject;其每个 API 的含义:
Subject:主体,代表了当前 “用户”,这个用户不一定是一个具体的人,与当前应用交互的任何东西都是 Subject,如网络爬虫,机器人等;即一个抽象概念;所有 Subject 都绑定到 SecurityManager,与 Subject 的所有交互都会委托给 SecurityManager;可以把 Subject 认为是一个门面;SecurityManager 才是实际的执行者;
SecurityManager:安全管理器;即所有与安全有关的操作都会与 SecurityManager 交互;且它管理着所有 Subject;可以看出它是 Shiro 的核心,它负责与后边介绍的其他组件进行交互,如果学习过 SpringMVC,你可以把它看成 DispatcherServlet 前端控制器;
Realm:域,Shiro 从从 Realm 获取安全数据(如用户、角色、权限),就是说 SecurityManager 要验证用户身份,那么它需要从 Realm 获取相应的用户进行比较以确定用户身份是否合法;也需要从 Realm 得到用户相应的角色 / 权限进行验证用户是否能进行操作;可以把 Realm 看成 DataSource,即安全数据源。
也就是说对于我们而言,最简单的一个 Shiro 应用:
- 应用代码通过 Subject 来进行认证和授权,而 Subject 又委托给 SecurityManager;
- 我们需要给 Shiro 的 SecurityManager 注入 Realm,从而让 SecurityManager 能得到合法的用户及其权限进行判断。
从以上也可以看出,Shiro 不提供维护用户 / 权限,而是通过 Realm 让开发人员自己注入。
看懂了吗
其实上面的概念我们大概知道就行了,没必要死磕到底,如果还是想了解多一点概念可以点击我。其实对于新手来说,更重要的是能自己实践出来即可。最近玩的项目用到了shiro框架身份认证和权限,所以总结了一下springboot整合shiro框架的应用。
代码实现
导入依赖
<?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 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.3.1.RELEASE</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>com.ao</groupId>
<artifactId>demo</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>demo</name>
<description>Demo project for Spring Boot</description>
<properties>
<java.version>1.8</java.version>
</properties>
<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>
<exclusions>
<exclusion>
<groupId>org.junit.vintage</groupId>
<artifactId>junit-vintage-engine</artifactId>
</exclusion>
</exclusions>
</dependency>
<!--shiro依赖包-->
<dependency>
<groupId>org.apache.shiro</groupId>
<artifactId>shiro-spring-boot-web-starter</artifactId>
<version>1.4.0</version>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>8.0.18</version>
</dependency>
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>druid-spring-boot-starter</artifactId>
<version>1.1.21</version>
</dependency>
<!--mybatis-plus依赖包-->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.3.0</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.8</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
shiro配置
ShiroConfig
package com.ao.demo.config;
import com.ao.demo.shiro.AdminAuthorizingRealm;
import com.ao.demo.shiro.AdminWebSessionManager;
import org.apache.shiro.mgt.SecurityManager;
import org.apache.shiro.realm.Realm;
import org.apache.shiro.session.mgt.SessionManager;
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 {
//Filter工厂,设置对应的过滤条件和跳转条件
@Bean
public ShiroFilterFactoryBean shiroFilterFactoryBean(SecurityManager securityManager) {
ShiroFilterFactoryBean shiroFilterFactoryBean = new ShiroFilterFactoryBean();
shiroFilterFactoryBean.setSecurityManager(securityManager);
Map<String, String> filterChainDefinitionMap = new LinkedHashMap<String, String>();
/*anon: 无需认证即可访问
authc: 需要认证才可访问
user: 点击“记住我”功能可访问
perms: 拥有权限才可以访问
role: 拥有某个角色权限才能访问*/
filterChainDefinitionMap.put("/admin/auth/login", "anon");
filterChainDefinitionMap.put("/admin/**", "authc");
/*没有登录的用户请求需要登录的页面时自动跳转到登录页面。*/
shiroFilterFactoryBean.setLoginUrl("/admin/auth/401");
/*登录成功默认跳转页面,不配置则跳转至”/”,可以不配置,直接通过代码进行处理。*/
shiroFilterFactoryBean.setSuccessUrl("/admin/auth/success");
/*没有权限默认跳转的页面,登录的用户访问了没有被授权的资源自动跳转到的页面*/
shiroFilterFactoryBean.setUnauthorizedUrl("/admin/auth/403");
shiroFilterFactoryBean.setFilterChainDefinitionMap(filterChainDefinitionMap);
return shiroFilterFactoryBean;
}
//将自己的验证方式加入容器
@Bean
public Realm realm() {
return new AdminAuthorizingRealm();
}
//管理会话
@Bean
public SessionManager sessionManager() {
return new AdminWebSessionManager();
}
//权限管理,配置主要是Realm的管理认证
@Bean
public DefaultWebSecurityManager defaultWebSecurityManager() {
DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager();
securityManager.setRealm(realm());
securityManager.setSessionManager(sessionManager());
return securityManager;
}
}
配置自己的验证方式
AdminAuthorizingRealm
package com.ao.demo.shiro;
import com.ao.demo.pojo.Admin;
import com.ao.demo.service.IAdminService;
import com.ao.demo.service.IPermissionService;
import com.ao.demo.service.IRoleService;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import org.apache.shiro.authc.*;
import org.apache.shiro.authz.AuthorizationException;
import org.apache.shiro.authz.AuthorizationInfo;
import org.apache.shiro.authz.SimpleAuthorizationInfo;
import org.apache.shiro.realm.AuthorizingRealm;
import org.apache.shiro.subject.PrincipalCollection;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.util.Assert;
import java.util.List;
import java.util.Set;
@Slf4j
public class AdminAuthorizingRealm extends AuthorizingRealm {
/*当调用判断权限的方法, 才会触发 doGetAuthorizationInfo() 方法
subject.hasRole();
subject.checkPermission();
subject.isPermitted();
....
*/
@Override
protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) {
log.info("----------doGetAuthorizationInfo方法被调用----------");
if (principals == null) {
throw new AuthorizationException("");
}
Admin admin = (Admin) getAvailablePrincipal(principals);
String[] roleIds = admin.getRoleIds();
Set<String> roles = new HashSet<>();
roles.add("role1");
Set<String> permissions = new HashSet<>();
permissions.add("order:list");
SimpleAuthorizationInfo info = new SimpleAuthorizationInfo();
info.setRoles(roles);
info.setStringPermissions(permissions);
return info;
}
// 3.在认证方法中的doGetAuthenticationInfo
@Override
protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException {
UsernamePasswordToken upToken = (UsernamePasswordToken) token;
String username = upToken.getUsername(); //获取到用户名
String password = new String(upToken.getPassword()); //获取到密码
// 查询数据库是否有这个用户,此处是mybatisplus的写法
List<Admin> adminList = adminService.list(new QueryWrapper<Admin>().lambda().eq(Admin::getUsername, username));
Assert.state(adminList.size() < 2, "同一个用户名存在两个账户");
if (adminList.size() == 0) {
throw new UnknownAccountException("找不到用户(" + username + ")的帐号信息");
}
Admin admin = adminList.get(0);
if (admin.getState() == 1){
throw new LockedAccountException("帐号待审核");
}
if (admin.getState() == 2){
throw new DisabledAccountException("帐号已禁用");
}
if (admin.getState() == 3){
throw new ExcessiveAttemptsException("帐号已锁定");
}
/*.......一些业务逻辑*/
//这里如果上面的都成立后会进行密码校验,第二个参数是用户数据库的密码
return new SimpleAuthenticationInfo(admin, admin.getPassword(), getName());
}
}
会话管理
AdminWebSessionManager
package com.ao.demo.shiro;
import org.apache.shiro.web.servlet.ShiroHttpServletRequest;
import org.apache.shiro.web.session.mgt.DefaultWebSessionManager;
import org.apache.shiro.web.util.WebUtils;
import org.springframework.util.StringUtils;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import java.io.Serializable;
public class AdminWebSessionManager extends DefaultWebSessionManager {
public static final String LOGIN_TOKEN_KEY = "Shiro-Token";
private static final String REFERENCED_SESSION_ID_SOURCE = "shiro request";
/**
* 获取session id
* 从请求头中获取jsesssionid
*/
@Override
protected Serializable getSessionId(ServletRequest request, ServletResponse response) {
// 从请求头中获取token
String id = WebUtils.toHttp(request).getHeader(LOGIN_TOKEN_KEY);
// 判断是否有值
if (!StringUtils.isEmpty(id)) {
// 设置当前session状态
request.setAttribute(ShiroHttpServletRequest.REFERENCED_SESSION_ID_SOURCE, REFERENCED_SESSION_ID_SOURCE);
request.setAttribute(ShiroHttpServletRequest.REFERENCED_SESSION_ID, id);
request.setAttribute(ShiroHttpServletRequest.REFERENCED_SESSION_ID_IS_VALID, Boolean.TRUE);
return id;
} else {
// 若header获取不到token则尝试从cookie中获取
return super.getSessionId(request, response);
}
}
}
controller
AdminAuthController
package com.ao.demo.controller;
import com.ao.demo.pojo.Admin;
import com.ao.demo.utils.JacksonUtil;
import lombok.extern.slf4j.Slf4j;
import org.apache.shiro.SecurityUtils;
import org.apache.shiro.authc.*;
import org.apache.shiro.subject.Subject;
import org.springframework.web.bind.annotation.*;
import javax.servlet.http.HttpServletRequest;
@RestController
@RequestMapping("/admin/auth")
@Slf4j
public class AdminAuthController {
@PostMapping("/login")
public Object login(@RequestBody Admin loginAdmin) {
// 1.SecurityUtils:是shiro的一个工具类。通过SecurityUtils获取Subject
Subject currentUser = SecurityUtils.getSubject();
try {
/*UsernamePasswordToken是一个简单的包含username及password即用户名及密码的登录验证用token*/
//2、new一个 UsernamePasswordToken,并传上用户名及密码。把返回值传给登入作为条件
//3、当调用subject的登入方法时,会跳转到reaml认证的方法(doGetAuthenticationInfo)上。
currentUser.login(new UsernamePasswordToken(loginAdmin.getUsername(), loginAdmin.getPassword()));
} catch (UnknownAccountException uae) {
return uae.getMessage();
} catch (LockedAccountException lae) {
return lae.getMessage();
} catch (DisabledAccountException dae) {
return dae.getMessage();
}catch (AuthenticationException ae) {
return "认证失败";
}
//是否有role1这个角色
if(currentUser.hasRole("role1")){
log.info("有角色role1");
}else{
log.info("没有角色role1");
}
//查看是否有查看订单的权限
if(currentUser.isPermitted("order:list")){
log.info("拥有查看订单的权限");
}else {
log.info("没有查看订单的权限");
}
//4、在认证方法中subject已经把获取到了用户,所以我们用subject.getPrincipal 可以获取到登录的用户
Admin admin = (Admin) SecurityUtils.getSubject().getPrincipal();
return "登录成功,用户信息:"+admin+"token:" + currentUser.getSession().getId();
}
@PostMapping("/tt")
public String tt(){
return "访问成功";
}
}
测试
表的数据
测试一波
可以看到doGetAuthorizationInfo被执行了两次,这是因为我在controller调用了hasRole和isPermitted。
登录成功,返回了token(网上说这个token的过期时间是30分钟,具体需要查证源码)
登录失败,执行到new SimpleAuthenticationInfo(admin, admin.getPassword(), getName())这里,密码错误抛出AuthenticationException,controller捕获到所以返回结果认证失败。
再测试一下会话管理
登录成功了,此时的token是:81973a5e-6e4c-4d7a-a6a6-7b212eea2b97
然后我们验证一下会话管理起不起作用
根据返回来的报文,没有登录的用户请求需要登录的页面时自动跳转到登录页面,这就是我们在shiroconfig设置的setLoginUrl,因为这个接口没写,所以404。接下来拿到登录用户的token,进行访问,结果是预期的。