在 Spring 中,流行的涉及权限管理的框架有两个:Spring Security 和 Apache Shiro。但是去了解一下 Spring Security 就知道,简单的权限管理根本用不到那么复杂的功能。在自己的项目中,我更倾向于使用简单明了的 Apache Shiro。
我们以最常见的用户、角色、权限
关系做例子。一个用户有多个角色、一个角色有多个用户、一个角色有多个权限、一个权限有多个角色。即用户与角色、角色与权限是多对多关系。
引入 shiro-spring 包
pom包依赖
重要的是 shiro-spring 这个包。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
| <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-jpa</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-thymeleaf</artifactId> </dependency>
<dependency> <groupId>org.mariadb.jdbc</groupId> <artifactId>mariadb-java-client</artifactId> <version>RELEASE</version> </dependency>
<dependency> <groupId>org.apache.shiro</groupId> <artifactId>shiro-spring</artifactId> <version>RELEASE</version> </dependency>
|
配置文件
1 2 3 4 5 6 7 8 9 10 11 12
| spring.datasource.driver-class-name=org.mariadb.jdbc.Driver spring.datasource.url=jdbc:mysql://localhost:3306/test spring.datasource.username=test spring.datasource.password=test
spring.jpa.database=mysql spring.jpa.show-sql=true spring.jpa.hibernate.ddl-auto=update
spring.thymeleaf.cache=false
spring.jackson.serialization.indent-output=true
|
实体类
用户类
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34
| package me.xlui.spring.entity;
import javax.persistence.*; import java.io.Serializable; import java.util.List;
@Entity @Table(name = "shiro_user") public class User implements Serializable { private static final long serialVersionUID = 1L;
@Id @GeneratedValue private Long id; @Column(name = "username", nullable = false, unique = true) private String username; private String password; private String salt;
@ManyToMany(fetch = FetchType.EAGER) @JoinTable(name = "shiro_user_role", joinColumns = {@JoinColumn(name = "user_id")}, inverseJoinColumns = {@JoinColumn(name = "role_id")}) private List<Role> roleList;
@Override public String toString() { return "User[id = " + id + ", username = " + username + ", password = " + password + ", salt = " + salt + "]"; }
public User() { super(); }
}
|
角色类
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30
| package me.xlui.spring.entity;
import javax.persistence.*; import java.io.Serializable; import java.util.List;
@Entity @Table(name = "shiro_role") public class Role implements Serializable { private static final long serialVersionUID = 1L;
@Id @GeneratedValue private Long id; private String role;
@ManyToMany @JoinTable(name = "shiro_user_role", joinColumns = {@JoinColumn(name = "role_id")}, inverseJoinColumns = {@JoinColumn(name = "user_id")}) private List<User> userList;
@ManyToMany @JoinTable(name = "shiro_role_permission", joinColumns = {@JoinColumn(name = "role_id")}, inverseJoinColumns = {@JoinColumn(name = "permission_id")}) private List<Permission> permissionList;
public Role() { super(); }
}
|
权限类
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26
| package me.xlui.spring.entity;
import javax.persistence.*; import java.io.Serializable; import java.util.List;
@Entity @Table(name = "shiro_permission") public class Permission implements Serializable { private static final long serialVersionUID = 1L;
@Id @GeneratedValue private Long id; private String permission;
@ManyToMany @JoinTable(name = "shiro_role_permission", joinColumns = {@JoinColumn(name = "permission_id")}, inverseJoinColumns = {@JoinColumn(name = "role_id")}) private List<Role> roleList;
public Permission() { super(); }
}
|
初始化数据库表
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| INSERT INTO shiro_user (id, password, salt, username) VALUES (1, "dev", "salt", "admin");
INSERT INTO shiro_role (id, role) VALUES (1, "admin"), (2, "normal");
INSERT INTO shiro_permission (id, permission) VALUES (1, "user info"), (2, "user add"), (3, "user del");
INSERT INTO shiro_user_role (user_id, role_id) VALUES (1, 1);
INSERT INTO shiro_role_permission (permission_id, role_id) VALUES (1, 1), (2, 1);
|
查询接口
UserRepository:
1 2 3 4 5 6 7 8
| package me.xlui.spring.repository;
import me.xlui.spring.entity.User; import org.springframework.data.jpa.repository.JpaRepository;
public interface UserRepository extends JpaRepository<User, Long> { User findByUsername(String username); }
|
其他省略。
Shiro 配置
Apache Shiro 核心通过 Filter 实现,是基于 URL 规则来进行过滤和权限校验。我们通过注入类来进行 Shiro 的配置:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62
| package me.xlui.spring.config;
import org.apache.shiro.authc.credential.CredentialsMatcher; import org.apache.shiro.authc.credential.HashedCredentialsMatcher; import org.apache.shiro.mgt.SecurityManager; import org.apache.shiro.spring.security.interceptor.AuthorizationAttributeSourceAdvisor; 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 ShiroConfiguration {
@Bean public AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor(SecurityManager securityManager) { AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor = new AuthorizationAttributeSourceAdvisor(); authorizationAttributeSourceAdvisor.setSecurityManager(securityManager); return authorizationAttributeSourceAdvisor; }
@Bean public MyShiroRealm myShiroRealm() { return new MyShiroRealm(); }
@Bean public SecurityManager securityManager() { DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager(); securityManager.setRealm(myShiroRealm()); return securityManager; }
@Bean public ShiroFilterFactoryBean shiroFilterFactoryBean(SecurityManager securityManager) { ShiroFilterFactoryBean shiroFilterFactoryBean = new ShiroFilterFactoryBean(); shiroFilterFactoryBean.setSecurityManager(securityManager);
Map<String, String> filterChainDefinitionMap = new LinkedHashMap<>(); filterChainDefinitionMap.put("/logout", "logout"); filterChainDefinitionMap.put("/static", "anon"); filterChainDefinitionMap.put("/**", "authc");
shiroFilterFactoryBean.setLoginUrl("/login"); shiroFilterFactoryBean.setSuccessUrl("/index");
shiroFilterFactoryBean.setFilterChainDefinitionMap(filterChainDefinitionMap); return shiroFilterFactoryBean; } }
|
通过 ShiroFilterFactoryBean
来处理拦截资源文件的问题(单独的 ShiroFilterFactoryBean 配置会出错,因为 Shiro 还需要 SecurityManager)。
ShiroFilterFactory 中已经由 Shiro 官方实现的过滤器(只列举常用的):
Filter Name |
作用 |
anon |
匿名可访问 |
authc |
需要认证 |
user |
配置记住我或认证可访问 |
Shiro 认证和授权
Shiro 的认证、授权最终都交给 Realm 来处理,同时在 Shiro 中,用户、角色和权限等信息也是在 Realm 中获取。我们要做的是自定义一个类,继承抽象基类 AuthorizingRealm:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72
| package me.xlui.spring.config;
import me.xlui.spring.entity.Permission; import me.xlui.spring.entity.Role; import me.xlui.spring.entity.User; import me.xlui.spring.repository.UserRepository; import me.xlui.spring.utils.LogUtil; import org.apache.shiro.authc.AuthenticationException; import org.apache.shiro.authc.AuthenticationInfo; import org.apache.shiro.authc.AuthenticationToken; import org.apache.shiro.authc.SimpleAuthenticationInfo; import org.apache.shiro.authc.credential.CredentialsMatcher; import org.apache.shiro.authc.credential.HashedCredentialsMatcher; 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.apache.shiro.util.ByteSource; import org.springframework.beans.factory.annotation.Autowired;
public class MyShiroRealm extends AuthorizingRealm { @Autowired private UserRepository userRepository;
@Override protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principalCollection) { LogUtil.getLogger().info("权限配置:MyShiroRealm.doGetAuthorizationInfo"); SimpleAuthorizationInfo authorizationInfo = new SimpleAuthorizationInfo(); User user = (User) principalCollection.getPrimaryPrincipal(); LogUtil.getLogger().info("为用户 " + user.getUsername() + " 进行权限配置");
for (Role role : user.getRoleList()) { authorizationInfo.addRole(role.getRole()); for (Permission permission : role.getPermissionList()) { authorizationInfo.addStringPermission(permission.getPermission()); } }
return authorizationInfo; }
@Override protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authenticationToken) throws AuthenticationException { LogUtil.getLogger().info("开始身份认证"); String username = (String) authenticationToken.getPrincipal(); LogUtil.getLogger().info("输入得到的用户名:" + username); User user = userRepository.findByUsername(username);
if (user == null) { return null; }
LogUtil.getLogger().info("用户信息:\n" + user.toString()); SimpleAuthenticationInfo authenticationInfo = new SimpleAuthenticationInfo( user, user.getPassword(), getName() ); return authenticationInfo; } }
|
继承 AuthorizingRealm 类需要实现两个方法:doGetAuthorizationInfo() 和 doGetAuthenticationInfo()。doGetAuthorizationInfo()
负责权限管理,即为用户设置允许的权限,doGetAuthenticationInfo()
方法负责身份认证,即检验用户名密码的正确性。
1 2 3 4 5 6
| SimpleAuthenticationInfo authenticationInfo = new SimpleAuthenticationInfo( user, user.getPassword(),
getName() );
|
默认使用 CredentialsMatcher
来进行用户名密码确认,如果觉得默认的不好可以自己手动实现,下面讲密码加密存储会涉及到。注释的一行是密码加密时使用的盐,如果是明文密码去掉注释的一行即可。
接下来需要把自定义的 Realm 注入到 SecurityManager 中,代码在 ShiroConfiguration 类中已经实现:
1 2 3 4 5 6 7 8 9 10 11
| @Bean public MyShiroRealm myShiroRealm() { return new MyShiroRealm(); }
@Bean public SecurityManager securityManager() { DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager(); securityManager.setRealm(myShiroRealm()); return securityManager; }
|
测试
经过上面的操作 Shiro 的集成基本已经是完成了,下面进行测试:
控制器
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52
| package me.xlui.spring.web;
import me.xlui.spring.entity.User; import me.xlui.spring.repository.UserRepository; import me.xlui.spring.utils.LogUtil; import org.apache.shiro.authc.IncorrectCredentialsException; import org.apache.shiro.authc.UnknownAccountException; import org.apache.shiro.authz.annotation.RequiresPermissions; import org.apache.shiro.crypto.hash.SimpleHash; import org.apache.shiro.util.ByteSource; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.ResponseBody;
import javax.servlet.http.HttpServletRequest; import java.util.Map;
@Controller public class HelloController { @Autowired private UserRepository userRepository;
@RequestMapping({"/", "/index"}) public String index() { LogUtil.getLogger().info("HelloController.index"); return "index"; }
@RequestMapping("/login") public String login(HttpServletRequest request, Map<String, Object> map) throws Exception { LogUtil.getLogger().info("HelloController.login"); String exception = (String) request.getAttribute("shiroLoginFailure"); String msg = ""; if (exception != null) { if (UnknownAccountException.class.getName().equals(exception)) { LogUtil.getLogger().info("账户不存在!"); msg = "账户不存在"; } else if (IncorrectCredentialsException.class.getName().equals(exception)) { LogUtil.getLogger().info("密码不正确!"); msg = "密码错误"; } else { LogUtil.getLogger().info("发生异常:" + exception); msg = "其他异常"; } }
map.put("msg", msg); return "login"; } }
|
模板
index.html:
1 2 3 4 5 6 7 8 9 10
| <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"/> <title>Index</title> </head> <body> <p>这是 Index 页!</p> </body> </html>
|
login.html:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| <!DOCTYPE html> <html xmlns:th="http://www.thymeleaf.org"> <head> <meta charset="UTF-8"/> <title>Login</title> </head> <body> 错误信息:<h4 style="color: red;" th:text="${msg}"></h4> <form th:action="@{/login}" method="post"> <p>用户名:<input type="text" name="username" autofocus="autofocus"/></p> <p>密码:<input type="password" name="password"/></p> <p><input type="submit" value="登录"/></p> </form> </body> </html>
|
运行
运行项目,访问 http://localhost:8080/。
密码加盐加密存储
实际应用中我们往往不会直接明文存储密码,因为这样非常不安全。而单纯的使用 MD5、SHA 之类的算法加密密码会存在数据库中两个密码相同用户的 password
字段也相同的情况,这样也很容易被撞库攻击。一种更安全的方式是加盐加密。
加盐加密的思路是在使用 MD5、SHA 之类算法的时候在用户的密码字段加一个随机、唯一的字符串(盐),这样生成的加密密码串几乎不可能存在相同的。即使是两个相同的密码,因为盐的不同,生成的密码串也是万万不同的。
生成加盐密码串
我采用的是访问特定 url 生成加盐密码串存储,实际应用的时候可以直接在用户注册或者修改密码的时候生成。
1 2 3 4 5 6 7 8 9 10
| @RequestMapping("/en") @ResponseBody public String encrypt() { User user = userRepository.findByUsername("admin"); user.setSalt(user.getUsername()); user.setPassword((new SimpleHash("MD5", user.getPassword(), ByteSource.Util.bytes(user.getSalt()), 1024)).toString()); userRepository.save(user); return "succ"; }
|
SimpleHash
是 Shiro 提供给我们的加密类,第一个参数是加密算法名,第二个参数是原密码,第三个参数是盐,因为在 Realm 中向 SimpleAuthenticationInfo
类传递参数时需要 ByteSource 类实例,所以我们在这里使用了相同的格式。实际上 SimpleHash 类对盐的具体类型没有要求,其形参的类型是 Object。第四个参数是加密的次数。
我们用自己的方式生成了加盐加密的密码串,接下来还需要告诉 Shiro 使用这种方式验证。
注入加密方式
本来我们应该编写一个加密算法类,但是 Shiro 已经替我们实现了,HashedCredentialsMatcher
,我们只需要注入使用即可。有两种使用方式:
(1)重写 MyShiroRealm
(自定义 Realm 类)的 setCredentialsMatcher
方法:
1 2 3 4 5 6 7 8 9 10 11 12
| public class MyShiroRealm extends AuthorizingRealm {
@Override public void setCredentialsMatcher(CredentialsMatcher credentialsMatcher) { HashedCredentialsMatcher hashedCredentialsMatcher = new HashedCredentialsMatcher(); hashedCredentialsMatcher.setHashAlgorithmName("MD5"); hashedCredentialsMatcher.setHashIterations(1024); super.setCredentialsMatcher(hashedCredentialsMatcher); } }
|
(2)在 ShiroConfiguration
中注入:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| @Configuration public class ShiroConfiguration {
@Bean public HashedCredentialsMatcher hashedCredentialsMatcher(){ HashedCredentialsMatcher hashedCredentialsMatcher = new HashedCredentialsMatcher(); hashedCredentialsMatcher.setHashAlgorithmName("md5"); hashedCredentialsMatcher.setHashIterations(1024); return hashedCredentialsMatcher; }
@Bean public MyShiroRealm myShiroRealm() { MyShiroRealm myShiroRealm = new MyShiroRealm(); myShiroRealm.setCredentialsMatcher(hashedCredentialsMatcher()); return myShiroRealm; } }
|
两种方法无太大优劣,都可以成功告知 Shiro 使用这种方式进行加盐密码验证。
如果觉得默认的 HashedCredentialsMatcher
不好,可以自己动手实现一个,继承 CredentialsMatcher
接口,然后按照上面的方法集成即可。
验证
运行程序,访问 http://localhost:8080/en
,跳转到登录,登录后返回,对密码进行加盐存储。
查看数据库中用户表相应字段是否更新。
关闭浏览器,重新访问 http://localhost:8080
使用原用户名密码成功登录。
吐槽
Spring Boot 和 Shiro 似乎存在一些问题。我一般开发的时候都在配置文件(application.properties)中这样设置:
1
| spring.jpa.hibernate.ddl-auto=create
|
然后再 classpath 也就是 src/main/resources 下新建 data.sql。这样 Spring Boot 在启动的时候就会删除所有相关表重建并且执行 data.sql 中的语句进行初始化。
但是在使用 Shiro 的情况下 data.sql 一直无法成功执行。Google 和 StackOverflow 都没有发现理想的回答[摊手]。
还有就是关于密码加盐存储这一点,百度到的博客基本就是抄来抄去,大部分只提了如何给密码加盐,基本没提到加盐存储之后 Shiro 如何验证。
源码
源代码已经上传 GitHub:https://github.com/xlui/Spring-Boot-Examples/tree/master/spring-boot-shiro。如果对你有所帮助,不妨留个 star 再走。