Shiro 是一个强大、简单易用的 Java 安全框架,可使认证、授权、加密,会话过程更便捷,并可为应用提供安全保障。本节课重点介绍下 Shiro 的认证和授权功能。
Shiro 有三大核心组件,即 Subject、SecurityManager 和 Realm。先来看一下它们之间的关系。
包含 Principals 和 Credentials 两个信息。我们看下两者的具体含义。
Principals:代表身份。可以是用户名、邮件、手机号码等等,用来标识一个登录主体的身份。
Credentials:代表凭证。常见的有密码,数字证书等等。
说白了,两者代表了需要认证的内容,最常见的便是用户名、密码了。比如用户登录时,通过 Shiro 进行身份认证,其中就包括主体认证。
这是 Shiro 架构的核心,是 Shiro 内部所有原件的保护伞。项目中一般都会配置 SecurityManager,开发人员将大部分精力放在了 Subject 认证主体上,与 Subject 交互背后的安全操作,则由 SecurityManager 来完成。
它是连接 Shiro 和具体应用的桥梁。当需要与安全数据交互时,比如用户账户、访问控制等,Shiro 将会在一个或多个 Realm 中查找。我们可以把 Realm 看作 DataSource,即安全数据源。一般,我们会自己定制 Realm,下文会详细说明。
我们分析下 Shiro 身份认证的过程,首先看一下官方给出的认证图。
从图中可以看到,这个过程包括五步。
Step1:应用程序代码调用 Subject.login(token) 方法后,传入代表最终用户身份的 AuthenticationToken 实例 Token。
Step2:将 Subject 实例委托给应用程序的 SecurityManager(Shiro 的安全管理)并开始实际的认证工作。这里开始了真正的认证工作。
Step3、4、5:SecurityManager 根据具体的 Realm 进行安全认证。从图中可以看出,Realm 可进行自定义(Custom Realm)。
权限认证,也就是访问控制,即在应用中控制谁能访问哪些资源。在权限认证中,最核心的三个要素是:权限、角色和用户。
权限(Permission):即操作资源的权利,比如访问某个页面,以及对某个模块的数据进行添加、修改、删除、查看操作的权利。
角色(Role):指的是用户担任的角色,一个角色可以有多个权限。
用户(User):在 Shiro 中,代表访问系统的用户,即上面提到的 Subject 认证主体。
它们之间的的关系可以用下图来表示:
一个用户可以有多个角色,而不同的角色可以有不同的权限,也可有相同的权限。比如说现在有三个角色,1 是普通角色,2 也是普通角色,3 是管理员,角色 1 只能查看信息,角色 2 只能添加信息,管理员对两者皆有权限,而且还可以删除信息。
Spring Boot 2.0.3 集成 Shiro 需要导入如下 starter 依赖:
org.apache.shiro
shiro-spring
1.4.0
这里主要涉及到三张表:用户表、角色表和权限表。其实在 Demo 中,我们完全可以自己来模拟数据库操作,不用建表,但为了更加接近实际情况,我们还是引入了 MyBatis 来操作数据库。下面是数据库各表的创建脚本。
CREATE TABLE `t_role` (
`id` int(11) NOT NULL AUTO_INCREMENT COMMENT '主键',
`rolename` varchar(20) DEFAULT NULL COMMENT '角色名称',
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=4 DEFAULT CHARSET=utf8
CREATE TABLE `t_user` (
`id` int(11) NOT NULL AUTO_INCREMENT COMMENT '用户主键',
`username` varchar(20) NOT NULL COMMENT '用户名',
`password` varchar(20) NOT NULL COMMENT '密码',
`role_id` int(11) DEFAULT NULL COMMENT '外键关联role表',
PRIMARY KEY (`id`),
KEY `role_id` (`role_id`),
CONSTRAINT `t_user_ibfk_1` FOREIGN KEY (`role_id`) REFERENCES `t_role` (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=4 DEFAULT CHARSET=utf8
CREATE TABLE `t_permission` (
`id` int(11) NOT NULL AUTO_INCREMENT COMMENT '主键',
`permissionname` varchar(50) NOT NULL COMMENT '权限名',
`role_id` int(11) DEFAULT NULL COMMENT '外键关联role',
PRIMARY KEY (`id`),
KEY `role_id` (`role_id`),
CONSTRAINT `t_permission_ibfk_1` FOREIGN KEY (`role_id`) REFERENCES `t_role` (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=3 DEFAULT CHARSET=utf8
其中,t_user、t_role 和 t_permission 分别存储了用户信息、角色信息和权限信息,表建立好后,我们往表里插入一些测试数据,比如下面这些数据。
t_user 表:
id username password role_id
1 csdn1 123456 1
2 csdn2 123456 2
3 csdn3 123456 3
t_role 表:
id rolename
1 admin
2 teacher
3 student
t_permission 表:
id permissionname role_id
1 user:* 1
2 student:* 2
解释一下这里的权限:user:* 表示权限可以是 user:create 或其他,* 表示一个占位符,可以自己定义,下文介绍 Shiro 配置时会对其再做详细说明。
有了数据库表和数据,我们开始自定义 Realm。自定义 Realm 需要继承 AuthorizingRealm 类,该类封装了很多方法,且继承自 Realm 类。
继承 AuthorizingRealm 类后,我们需要重写以下两个方法。
doGetAuthenticationInfo() 方法:用来验证当前登录的用户,获取认证信息。
doGetAuthorizationInfo() 方法:为当前登录成功的用户授予权限和分配角色。
具体实现如下,相关注解请见代码注释:
/**
* 自定义realm
* @author shengwu ni
*/
public class MyRealm extends AuthorizingRealm {
@Resource
private UserService userService;
@Override
protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principalCollection) {
// 获取用户名
String username = (String) principalCollection.getPrimaryPrincipal();
SimpleAuthorizationInfo authorizationInfo = new SimpleAuthorizationInfo();
// 给该用户设置角色,角色信息存在 t_role 表中取
authorizationInfo.setRoles(userService.getRoles(username));
// 给该用户设置权限,权限信息存在 t_permission 表中取
authorizationInfo.setStringPermissions(userService.getPermissions(username));
return authorizationInfo;
}
@Override
protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authenticationToken) throws AuthenticationException {
// 根据 Token 获取用户名,如果您不知道该 Token 怎么来的,先可以不管,下文会解释
String username = (String) authenticationToken.getPrincipal();
// 根据用户名从数据库中查询该用户
User user = userService.getByUsername(username);
if(user != null) {
// 把当前用户存到 Session 中
SecurityUtils.getSubject().getSession().setAttribute("user", user);
// 传入用户名和密码进行身份认证,并返回认证信息
AuthenticationInfo authcInfo = new SimpleAuthenticationInfo(user.getUsername(), user.getPassword(), "myRealm");
return authcInfo;
} else {
return null;
}
}
}
从上面两个方法中可以看出,验证身份时需先根据用户输入的用户名从数据库中查出对应的用户,这时还未涉及到密码,也就是说即使用户输入的密码不正确,照样可以查询出该用户。
然后,将该用户的相关信息封装到 authcInfo 中并返回给 Shiro。接下来就该 Shiro 上场了,将封装的用户信息与用户的输入信息(用户名、密码)进行对比、校验(注意,这里对密码也要进行校验)。校验通过则允许用户登录,否则跳转到指定页面。
同理,权限验证时,也需先根据用户名从数据库中获取其对应的角色和权限,将其封装到 authorizationInfo 并返回给 Shiro。
自定义 Realm 写好了,接下来需要配置 Shiro。我们主要配置三个东西:自定义 Realm、安全管理器 SecurityManager 和 Shiro 过滤器。
首先,配置自定义的 Realm,代码如下:
@Configuration
public class ShiroConfig {
private static final Logger logger = LoggerFactory.getLogger(ShiroConfig.class);
/**
* 注入自定义的 Realm
* @return MyRealm
*/
@Bean
public MyRealm myAuthRealm() {
MyRealm myRealm = new MyRealm();
logger.info("====myRealm注册完成=====");
return myRealm;
}
}
接着,配置安全管理器 SecurityManager:
@Configuration
public class ShiroConfig {
private static final Logger logger = LoggerFactory.getLogger(ShiroConfig.class);
/**
* 注入安全管理器
* @return SecurityManager
*/
@Bean
public SecurityManager securityManager() {
// 将自定义 Realm 加进来
DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager(myAuthRealm());
logger.info("====securityManager注册完成====");
return securityManager;
}
}
配置 SecurityManager 时,需要将上面自定义 Realm 添加进来,这样 Shiro 才可访问该 Realm。
最后,配置 Shiro 过滤器:
@Configuration
public class ShiroConfig {
private static final Logger logger = LoggerFactory.getLogger(ShiroConfig.class);
/**
* 注入 Shiro 过滤器
* @param securityManager 安全管理器
* @return ShiroFilterFactoryBean
*/
@Bean
public ShiroFilterFactoryBean shiroFilter(SecurityManager securityManager) {
// 定义 shiroFactoryBean
ShiroFilterFactoryBean shiroFilterFactoryBean=new ShiroFilterFactoryBean();
// 设置自定义的 securityManager
shiroFilterFactoryBean.setSecurityManager(securityManager);
// 设置默认登录的 URL,身份认证失败会访问该 URL
shiroFilterFactoryBean.setLoginUrl("/login");
// 设置成功之后要跳转的链接
shiroFilterFactoryBean.setSuccessUrl("/success");
// 设置未授权界面,权限认证失败会访问该 URL
shiroFilterFactoryBean.setUnauthorizedUrl("/unauthorized");
// LinkedHashMap 是有序的,进行顺序拦截器配置
Map filterChainMap = new LinkedHashMap<>();
// 配置可以匿名访问的地址,可以根据实际情况自己添加,放行一些静态资源等,anon 表示放行
filterChainMap.put("/css/**", "anon");
filterChainMap.put("/imgs/**", "anon");
filterChainMap.put("/js/**", "anon");
filterChainMap.put("/swagger-*/**", "anon");
filterChainMap.put("/swagger-ui.html/**", "anon");
// 登录 URL 放行
filterChainMap.put("/login", "anon");
// 以“/user/admin” 开头的用户需要身份认证,authc 表示要进行身份认证
filterChainMap.put("/user/admin*", "authc");
// “/user/student” 开头的用户需要角色认证,是“admin”才允许
filterChainMap.put("/user/student*/**", "roles[admin]");
// “/user/teacher” 开头的用户需要权限认证,是“user:create”才允许
filterChainMap.put("/user/teacher*/**", "perms[\"user:create\"]");
// 配置 logout 过滤器
filterChainMap.put("/logout", "logout");
// 设置 shiroFilterFactoryBean 的 FilterChainDefinitionMap
shiroFilterFactoryBean.setFilterChainDefinitionMap(filterChainMap);
logger.info("====shiroFilterFactoryBean注册完成====");
return shiroFilterFactoryBean;
}
}
配置 Shiro 过滤器时,我们引入了安全管理器。
至此,我们可以看出,Shiro 配置一环套一环,遵循从 Reaml 到 SecurityManager 再到 Filter 的过程。在过滤器中,我们需要定义一个 shiroFactoryBean,然后将 SecurityManager 引入其中,需要配置的内容主要有以下几项。
通过上面的代码,我们也了解到, Map 中针对不同的 URL有不同的权限要求,下表总结了几个常用的权限。
至此,我们完成了 Shiro 的准备工作。接下来开始使用 Shiro 进行认证。
首先,设计如下几个接口。
接口一:使用 http://localhost:8080/user/admin 进行身份认证。
接口二:使用 http://localhost:8080/user/student 进行角色认证。
接口三:使用 http://localhost:8080/user/teacher 进行权限认证。
接口四:使用 http://localhost:8080/user/login 实现用户登录。
开始编码前,我们先了解下认证的流程。
流程一:直接访问接口一(此时还未登录),认证失败,跳转到 login.html 页面让用户登录。登录时请求接口四,实现用户登录,此时 Shiro 已经保存了用户信息。
流程二:再次访问接口一(此时用户已经登录),认证成功,跳转到 success.html 页面,展示用户信息。
流程三:访问接口二,测试角色认证是否成功。
流程四:访问接口三,测试权限认证是否成功。
接下来,编写身份、角色、权限认证接口,代码如下所示:
@Controller
@RequestMapping("/user")
public class UserController {
/**
* 身份认证测试接口
* @param request
* @return
*/
@RequestMapping("/admin")
public String admin(HttpServletRequest request) {
Object user = request.getSession().getAttribute("user");
return "success";
}
/**
* 角色认证测试接口
* @param request
* @return
*/
@RequestMapping("/student")
public String student(HttpServletRequest request) {
return "success";
}
/**
* 权限认证测试接口
* @param request
* @return
*/
@RequestMapping("/teacher")
public String teacher(HttpServletRequest request) {
return "success";
}
}
这三个接口很简单,直接返回到指定页面即可。认证成功正常跳转,如若认证失败,就会跳转到上文 ShrioConfig 中配置的页面。
之后,我们开始编写用户登录接口,如下所示:
@Controller
@RequestMapping("/user")
public class UserController {
/**
* 用户登录接口
* @param user user
* @param request request
* @return string
*/
@PostMapping("/login")
public String login(User user, HttpServletRequest request) {
// 根据用户名和密码创建 Token
UsernamePasswordToken token = new UsernamePasswordToken(user.getUsername(), user.getPassword());
// 获取 subject 认证主体
Subject subject = SecurityUtils.getSubject();
try{
// 开始认证,这一步会跳到我们自定义的 Realm 中
subject.login(token);
request.getSession().setAttribute("user", user);
return "success";
}catch(Exception e){
e.printStackTrace();
request.getSession().setAttribute("user", user);
request.setAttribute("error", "用户名或密码错误!");
return "login";
}
}
}
我们重点分析下用户登录接口。整个处理过程是这样的。
首先,根据前端传来的用户名和密码,创建一个 Token。
然后,使用 SecurityUtils 创建认证主体。
紧接着,调用 subject.login(token) 进行身份认证——注意,这里传入了刚刚创建的 Token,如注释所述,这一步会跳转入自定义的 Realm,访问 doGetAuthenticationInfo 方法,开始身份认证。
最后,启动项目,测试一下。在浏览器中请求:http://localhost:8080/user/admin, 首先进行身份认证,此时未登录,会跳转至 IndexController 中 /login 接口处,呈现出 login.html 页面供我们登录。
接着,使用用户名(csdn1)、密码(123456)登录,在浏览器中请求:http://localhost:8080/user/student 接口,开始角色认证,因为数据库中 csdn1 的用户角色是 admin,和配置中的吻合,认证通过。
我们再请求:http://localhost:8080/user/teacher 接口,进行权限认证,因为数据库中 csdn1 的用户权限为 user:*,满足配置中的 user:create,认证通过。
接下来,我们点击“退出”,系统会将该用户注销,让我们重新登录。我们尝试使用 csdn2 用户登录,重复上述操作,进行角色认证和权限认证时,因为数据库中 csdn2 用户的角色和权限与配置中的不同,所以认证失败。