作为一个Apache Shiro小白,最近跟着“纯洁的微笑”和“冷豪”等的博客,学习了一下Apache Shiro。将自己的一些简单理解记录下来,希望对你有所帮助。
Apache Shiro在我工作项目中主要用于登陆身份验证和访问权限控制。工作项目用的是SpringMVC框架,最近学习SpringBoot2,我就在SpringBoot2中来验证一下Apache Shiro。以下Apache Shiro简称Shiro。
一、Shiro登陆架构
下面是Shiro的用户登陆架构图,我们根据箭头来看一下流程。
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 通过DispachServletqu去实现。
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";
}
项目源码
其余未贴出代码请查看项目源码:https://github.com/DeppWang/SpringBoot-Demo
总结
通过ShiroFilter配置的过滤器,Shiro拦截所有未过滤的url。如果未登陆,跳转到登陆页(loginUrl)。直接使用表单,或者使用subject.login()登陆时。在doGetAuthenticationInfo()中验证。验证通过,跳转到成功页(successUrl)。
当访问需要访问权限的url时,从数据库查询当前用户的权限,最后交给Shiro框架去验证。在doGetAuthorizationInfo()中实现。
参考资料
springboot整合shiro-登录认证和权限管理:http://www.ityouknow.com/springboot/2017/06/26/springboot-shiro.html
30分钟学会如何使用Shiro:http://www.cnblogs.com/learnhow/p/5694876.html