#写在前面 前面我们使用Spring Boot+Shiro框架搭建的权限管理系统只使用于前后端一体化的项目,并不适用于当前最流行的前后端分离架构。要实现前后端分离,那么需要考虑两点:一是项目不再基于Session,那么此时如何确认访问者身份;二是如何确认访问者所具有的权限。如果你之前有过前后端分离的开发经验,肯定知道前后端分离一般依靠的都是token,即采用token来实现。用户登录时会生成token及token过期时间,而token与用户是一一对应关系,在调用接口的时候,将token放入header或者请求参数中,这样服务端就知道当前是哪个用户在调用接口。
由于本篇主要介绍如何实现前后端分离,因此关于前端的代码这里就忽略,详细的前端会在后面文章进行介绍。
新建SpringBoot项目 使用spring Initializr
构建工具构建一个SpringBoot的Web应用,名称为shiro-fontback
,然后在pom.xml文件中添加Shiro依赖以及Swagger依赖,代码为:
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 73 74 <dependencies> <!--starter--> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter</artifactId> </dependency> <!-- test--> <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> <!--validation--> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-validation</artifactId> </dependency> <!--JPA--> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-jpa</artifactId> </dependency> <!--JDBC--> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-jdbc</artifactId> </dependency> <!--lombok--> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> </dependency> <!-- shiro--> <dependency> <groupId>org.apache.shiro</groupId> <artifactId>shiro-spring</artifactId> <version>1.3.2</version> </dependency> <!--mysql-connector--> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <scope>runtime</scope> </dependency> <!-- druid-spring-boot-starter --> <dependency> <groupId>com.alibaba</groupId> <artifactId>druid-spring-boot-starter</artifactId> <version>1.1.10</version> </dependency> <!-- swagger --> <dependency> <groupId>com.spring4all</groupId> <artifactId>swagger-spring-boot-starter</artifactId> <version>1.8.0.RELEASE</version> </dependency> <!-- knife4j --> <dependency> <groupId>com.github.xiaoymin</groupId> <artifactId>knife4j-spring-boot-starter</artifactId> <version>2.0.2</version> </dependency> <!-- commons-lang --> <dependency> <groupId>commons-lang</groupId> <artifactId>commons-lang</artifactId> <version>2.6</version> </dependency> </dependencies>
修改配置文件 在application.yml
配置文件中配置项目所需的信息,代码如下所示:
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 # Tomcat server: tomcat: uri-encoding: UTF-8 max-threads: 1000 min-spare-threads: 30 port: 9090 connection-timeout: 5000ms servlet: context-path: /shiro #spring spring: jackson: time-zone: GMT+8 date-format: yyyy-MM-dd HH:mm:ss # 文件上传大小 servlet: multipart: max-file-size: 100MB max-request-size: 100MB enabled: true mvc: throw-exception-if-no-handler-found: true # 数据源 datasource: type: com.alibaba.druid.pool.DruidDataSource druid: driver-class-name: com.mysql.jdbc.Driver url: jdbc:mysql://127.0.01:3306/shirofontback?allowMultiQueries=true&useUnicode=true&characterEncoding=UTF-8&useSSL=false&serverTimezone=UTC username: root password: envy123 initial-size: 10 max-active: 100 min-idle: 10 max-wait: 60000 pool-prepared-statements: true max-pool-prepared-statement-per-connection-size: 20 time-between-eviction-runs-millis: 60000 min-evictable-idle-time-millis: 300000 test-while-idle: true test-on-borrow: false test-on-return: false stat-view-servlet: enabled: true url-pattern: /druid/* filter: stat: log-slow-sql: false slow-sql-millis: 1000 merge-sql: false wall: config: multi-statement-allow: true jpa: show-sql: true hibernate: ddl-auto: update database-platform: org.hibernate.dialect.MySQL5InnoDBDialect database: mysql swagger: enabled: true
创建实体类 新建entity包,并在其中创建四个实体类:User、Role、Permission和SysToken,各个实体类中的代码如下所示: 首先看一下用户信息表:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 //User.java @Data @Entity public class User { @Id private Integer userId; //主键 @Column(unique = true) private String username; //登录账户,注意值唯一 private String password; //密码 @ManyToMany(fetch = FetchType.EAGER) @JoinTable(name = "user_role", joinColumns = {@JoinColumn(name = "USER_ID",referencedColumnName = "userId")}, inverseJoinColumns = {@JoinColumn(name = "ROLE_ID",referencedColumnName = "roleId")} ) private Set<Role>roles; //用户角色 }
由于这里密码加盐不是重点,因此这里就不使用盐值。
再来看一下角色信息表:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 //Role.java @Data @Entity public class Role { @Id @GeneratedValue private Integer roleId; //主键 private String roleName; //角色名称,如admin/user private String description; //角色描述,注意这是用于UI显示用的 @ManyToMany(fetch = FetchType.EAGER) @JoinTable(name = "role_permission", joinColumns = {@JoinColumn(name = "ROLE_ID",referencedColumnName = "roleId")}, inverseJoinColumns = {@JoinColumn(name = "PERMISSION_ID",referencedColumnName = "permissionId")} ) private Set<Permission> permissions; //角色权限 }
再来看一下权限信息表:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 //Permission.java @Data @Entity public class Permission { @Id private Integer permissionId; //主键 private String permissionName; //权限名称,如user:add/user:delete private String description; //权限描述,注意这是用于UI显示用的 private String url; //权限地址 }
再来看一下token信息表,可以看到这里采用userId来和token进行一对一绑定,且token设置了过期时间和更新时间,这个在后续都会使用得到:
1 2 3 4 5 6 7 8 9 10 11 //SysToken.java @Entity @Data public class SysToken implements Serializable { @Id private Integer userId; //用户ID private String token; //token private LocalDateTime expireTime; //过期时间 private LocalDateTime updateTime; //更新时间 }
插入数据 在数据库中新建shirofontback数据库,然后启动项目,此时会在数据库中自动生成user(用户表)、role(角色表)、permission(权限表)、user_role(用户角色表)、role_permission(角色权限表)和sys_token(token信息表),为了后续测试需要,这里先往这些表中插入一些测试数据,如下所示:
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 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 use shirofontback; SET FOREIGN_KEY_CHECKS=0; -- ---------------------------- -- Table structure for hibernate_sequence -- ---------------------------- DROP TABLE IF EXISTS `hibernate_sequence`; CREATE TABLE `hibernate_sequence` ( `next_val` bigint(20) DEFAULT NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- ---------------------------- -- Records of hibernate_sequence -- ---------------------------- INSERT INTO `hibernate_sequence` VALUES ('1'); -- ---------------------------- -- Table structure for permission -- ---------------------------- DROP TABLE IF EXISTS `permission`; CREATE TABLE `permission` ( `permission_id` int(11) NOT NULL, `description` varchar(255) DEFAULT NULL, `permission_name` varchar(255) DEFAULT NULL, `url` varchar(255) DEFAULT NULL, PRIMARY KEY (`permission_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- ---------------------------- -- Records of permission -- ---------------------------- INSERT INTO `permission` VALUES ('1', '增加用户', 'user:add', '/save'); INSERT INTO `permission` VALUES ('2', '删除用户', 'user:delete', '/delete'); INSERT INTO `permission` VALUES ('3', '修改用户', 'user:edit', '/update'); INSERT INTO `permission` VALUES ('4', '查询用户', 'user:query', '/select'); -- ---------------------------- -- Table structure for role -- ---------------------------- DROP TABLE IF EXISTS `role`; CREATE TABLE `role` ( `role_id` int(11) NOT NULL, `description` varchar(255) DEFAULT NULL, `role_name` varchar(255) DEFAULT NULL, PRIMARY KEY (`role_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- ---------------------------- -- Records of role -- ---------------------------- INSERT INTO `role` VALUES ('1', '开发', 'develop'); INSERT INTO `role` VALUES ('2', '测试', 'test'); INSERT INTO `role` VALUES ('3', '运维', 'operate'); -- ---------------------------- -- Table structure for role_permission -- ---------------------------- DROP TABLE IF EXISTS `role_permission`; CREATE TABLE `role_permission` ( `role_id` int(11) NOT NULL, `permission_id` int(11) NOT NULL, PRIMARY KEY (`role_id`,`permission_id`), KEY `FKf8yllw1ecvwqy3ehyxawqa1qp` (`permission_id`), CONSTRAINT `FKa6jx8n8xkesmjmv6jqug6bg68` FOREIGN KEY (`role_id`) REFERENCES `role` (`role_id`), CONSTRAINT `FKf8yllw1ecvwqy3ehyxawqa1qp` FOREIGN KEY (`permission_id`) REFERENCES `permission` (`permission_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- ---------------------------- -- Records of role_permission -- ---------------------------- INSERT INTO `role_permission` VALUES ('1', '1'); INSERT INTO `role_permission` VALUES ('1', '3'); INSERT INTO `role_permission` VALUES ('1', '4'); -- ---------------------------- -- Table structure for sys_token -- ---------------------------- DROP TABLE IF EXISTS `sys_token`; CREATE TABLE `sys_token` ( `user_id` int(11) NOT NULL, `expire_time` datetime DEFAULT NULL, `token` varchar(255) DEFAULT NULL, `update_time` datetime DEFAULT NULL, PRIMARY KEY (`user_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- ---------------------------- -- Records of sys_token -- ---------------------------- INSERT INTO `sys_token` VALUES ('1', '2020-12-28 14:11:56', 'cd1a79b352ad88c2205d53747b786e6e', '2020-12-28 02:11:56'); -- ---------------------------- -- Table structure for user -- ---------------------------- DROP TABLE IF EXISTS `user`; CREATE TABLE `user` ( `user_id` int(11) NOT NULL, `password` varchar(255) DEFAULT NULL, `username` varchar(255) DEFAULT NULL, PRIMARY KEY (`user_id`), UNIQUE KEY `UK_sb8bbouer5wak8vyiiy4pf2bx` (`username`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- ---------------------------- -- Records of user -- ---------------------------- INSERT INTO `user` VALUES ('1', '1234', 'admin'); -- ---------------------------- -- Table structure for user_role -- ---------------------------- DROP TABLE IF EXISTS `user_role`; CREATE TABLE `user_role` ( `user_id` int(11) NOT NULL, `role_id` int(11) NOT NULL, PRIMARY KEY (`user_id`,`role_id`), KEY `FKa68196081fvovjhkek5m97n3y` (`role_id`), CONSTRAINT `FK859n2jvi8ivhui0rl0esws6o` FOREIGN KEY (`user_id`) REFERENCES `user` (`user_id`), CONSTRAINT `FKa68196081fvovjhkek5m97n3y` FOREIGN KEY (`role_id`) REFERENCES `role` (`role_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- ---------------------------- -- Records of user_role -- ---------------------------- INSERT INTO `user_role` VALUES ('1', '1'); INSERT INTO `user_role` VALUES ('1', '2');
创建Repository层 新建repository包,并在其中创建四个接口文件UserRepository、RoleRepository、PermissionRoleRepository和SysTokenRepository,各个接口中的文件如下所示:
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 //UserRepository.java public interface UserRepository extends JpaRepository<User,Integer> { /** * 通过用户名查找用户信息 * */ User findByUsername(String username); /** * 通过用户ID查找用户信息 * */ User findByUserId(Integer userId); } //RoleRepository.java public interface RoleRepository extends JpaRepository<Role,Integer> { } //PermissionRoleRepository.java public interface PermissionRoleRepository extends JpaRepository<Permission,Integer> { } //SysTokenRepository.java public interface SysTokenRepository extends JpaRepository<SysToken,Integer> { /** * 通过token查找SysToken信息 * */ SysToken findByToken(String token); /** * 通过userId查找SysToken信息 * */ SysToken findByUserId(Integer userId); }
创建Service层 新建service包,并在其中创建一个接口文件ShiroService:
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 public interface ShiroService { /** * 通过用户名查找用户信息 * */ User findByUsername(String username); /** * 通过用户ID查找用户信息 * */ User findByUserId(Integer userId); /** * 通过用户Id创建token * */ Map<String,Object> createToken(Integer userId); /** * 退出登录 * */ void logout(String token); /** * 通过token查找SysToken信息 * */ SysToken findByToken(String accessToken); }
之后在service包内新建一个impl包,并在impl包内新建一个ShiroServiceImpl类:
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 @Service public class ShiroServiceImpl implements ShiroService { //token过期时间设置为12小时 private final static Integer EXPIRE =12; @Autowired private UserRepository userRepository; @Autowired private SysTokenRepository sysTokenRepository; @Override public User findByUsername(String username) { return userRepository.findByUsername(username); } @Override public User findByUserId(Integer userId) { return userRepository.findByUserId(userId); } @Override public Map<String, Object> createToken(Integer userId) { Map<String,Object> map = new HashMap<>(); //生成一个token String token = TokenGenerator.generatorValue(); //获取当前时间 LocalDateTime currentTime = LocalDateTime.now(); //过期时间=当前时间+12个小时 LocalDateTime expireTime = currentTime.plusHours(EXPIRE); //判断是否生成过token SysToken sysToken = sysTokenRepository.findByUserId(userId); if(sysToken==null){ //没有生成过token sysToken = new SysToken(); sysToken.setUserId(userId); sysToken.setToken(token); sysToken.setExpireTime(expireTime); sysToken.setUpdateTime(currentTime); }else{ //已经生成过token,那么需要更新token sysToken.setToken(token); sysToken.setUpdateTime(currentTime); sysToken.setExpireTime(expireTime); } //保存Token sysTokenRepository.save(sysToken); map.put("token",token); map.put("expire",expireTime); return map; } /** * 更新数据库的token,使前端拥有的token失效 * 这里比较简单,假设用户退出就生成新的token,进而替换之前的token * */ @Override public void logout(String token) { SysToken byeToken = findByToken(token); token = TokenGenerator.generatorValue(); byeToken.setToken(token); sysTokenRepository.save(byeToken); } @Override public SysToken findByToken(String accessToken) { return sysTokenRepository.findByToken(accessToken); } }
可以看到在上面我们在创建token的方法createToken中调用了TokenGenerator.generatorValue()
方法,这是笔者自定义的方法,因此需要定义一个生成Token的方法。
定义生成Token的方法 新建auth包,并在其中创建一个Java文件TokenGenerator,其中的代码如下所示:
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 /** * token生成器 * */ public class TokenGenerator { public static String generatorValue(){ return generatorValue(UUID.randomUUID().toString()); } private static final char[] hexCode = "0123456789abcdefgh".toCharArray(); public static String toHexString(byte[] data){ if(data==null){ return null; } StringBuilder stringBuilder = new StringBuilder(data.length*2); for(byte d:data){ stringBuilder.append(hexCode[(d >>4) & 0xF]); stringBuilder.append(hexCode[(d & 0xF)]); } return stringBuilder.toString(); } /** * 生成Token * */ private static String generatorValue(String value){ try { MessageDigest algorithm = MessageDigest.getInstance("MD5"); algorithm.reset(); algorithm.update(value.getBytes()); byte[] messageDigest = algorithm.digest(); return toHexString(messageDigest); } catch (Exception e) { throw new RuntimeException("生成Token失败"); } } }
自定义AuthenticationToken 在前面我们生成token使用的都是UsernamePasswordToken,里面传入username和password参数,但是这里由于使用了自定义的token,或者说采用token来作为身份和凭证,因此就不能再使用默认的UsernamePasswordToken
,开发者可以自定义AuthenticationToken
,然后让它继承UsernamePasswordToken
类,并重写其中的getPrincipal
和getCredentials
方法,让它返回的身份和凭证都是token。在auth包内新建一个MyAuthToken类,让它继承UsernamePasswordToken类,相应的代码如下所示:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 /** * Shiro自定义token类 * 之后身份Principal不再返回username, * 凭证Credentials不再返回password, * 而是统一都返回token信息 * * */ public class MyAuthToken extends UsernamePasswordToken { private String token; public MyAuthToken(String token){ this.token = token; } @Override public Object getPrincipal() { return token; } @Override public Object getCredentials() { return token; } }
注意这里有人可能会说是否能直接实现AuthenticationToken接口,而在前面我们查看UsernamePasswordToken的源码可以发现它实现了HostAuthenticationToken和RememberMeAuthenticationToken接口:
1 public class UsernamePasswordToken implements HostAuthenticationToken, RememberMeAuthenticationToken {}
而上述两个接口都继承自AuthenticationToken这一接口:
1 2 3 4 5 6 7 public interface HostAuthenticationToken extends AuthenticationToken { String getHost(); } public interface RememberMeAuthenticationToken extends AuthenticationToken { boolean isRememberMe(); }
而这个AuthenticationToken接口中是存在getPrincipal
和getCredentials
方法的:
1 2 3 4 5 public interface AuthenticationToken extends Serializable { Object getPrincipal(); Object getCredentials(); }
笔者尝试直接实现AuthenticationToken接口,但是发现系统抛出异常。通过打断点测试发现这个token是在认证过程中调用的,而认证相关的信息都与AuthenticatingRealm类有关,于是查看了它的源码,发现了如下方法:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 public AuthenticatingRealm(CacheManager cacheManager, CredentialsMatcher matcher) { this.authenticationTokenClass = UsernamePasswordToken.class; this.authenticationCachingEnabled = false; int instanceNumber = INSTANCE_COUNT.getAndIncrement(); this.authenticationCacheName = this.getClass().getName() + ".authenticationCache"; if (instanceNumber > 0) { this.authenticationCacheName = this.authenticationCacheName + "." + instanceNumber; } if (cacheManager != null) { this.setCacheManager(cacheManager); } if (matcher != null) { this.setCredentialsMatcher(matcher); } }
注意这其中的authenticationTokenClass属性,它就是用于获取authenticationToken类的值,这里默认使用的是UsernamePasswordToken,因此上述配置是不够的,需要修改这个属性。那么问题来了,如何修改这个属性呢?笔者继续往下找,发现了这个属性的getter和setter方法:
1 2 3 4 5 6 7 public Class getAuthenticationTokenClass() { return this.authenticationTokenClass; } public void setAuthenticationTokenClass(Class<? extends AuthenticationToken> authenticationTokenClass) { this.authenticationTokenClass = authenticationTokenClass; }
但是这个似乎不太好用,然后我又发现了一个supports方法,这里面要求token不为空且传入的token类是当前token类的子类:
1 2 3 public boolean supports(AuthenticationToken token) { return token != null && this.getAuthenticationTokenClass().isAssignableFrom(token.getClass()); }
isAssignableFrom()
方法与instanceof
关键字的区别总结为以下两个点: (1)isAssignableFrom()
方法是从类继承的角度去判断,instanceof
关键字是从实例继承的角度去判断;(2)isAssignableFrom()
方法是判断是否为某个类的父类,instanceof
关键字是判断是否某个类的子类,如下所示:
1 2 3 父类.class.isAssignableFrom(子类.class) 子类实例 instanceof 父类类型
所以综合上述分析,这里提供两种方案,一种是继承UsernamePasswordToken类,另一种是实现AuthenticationToken接口,同时需要重写supports方法,个人推荐使用第一种方法。
自定义Realm 接下来需要和之前一样,用户在发起请求时,接受传过来的token之后,如何保证token的有效性?或者说如何通过token来与用户进行绑定,并使用token来标识用户和进行角色、权限检验。
关于这一点,Shiro框架提供了AuthorizingRealm和AuthenticatingFilter类,因此开发者只需AuthorizingRealm和AuthenticatingFilter类并重写相应的方法即可。
在auth包内新建一个MyShiroRealm类,让它继承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 /** * Shiro自定义Realm类 * * */ public class MyShiroRealm extends AuthorizingRealm { @Autowired private ShiroService shiroService; //认证 @Override protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authenticationToken) throws AuthenticationException { //1、获取用户身份,这里不再是默认的username,而是token(前端传来的token) String accessToken = (String) authenticationToken.getPrincipal(); //2、通过token去数据库中查询token信息 SysToken sysToken = shiroService.findByToken(accessToken); //3、判断token是否失效 if(sysToken==null || sysToken.getExpireTime().isBefore(LocalDateTime.now())){ //数据库中查不到对应的token信息或者token已经过期 throw new IncorrectCredentialsException("token失效,请重新登录"); } //4、通过token中的userId来查询用户信息 User user = shiroService.findByUserId(sysToken.getUserId()); if(user==null){ throw new UnknownAccountException("用户不存在"); } //5、根据用户信息来构建一个AuthenticationInfo对象,通常使用它的实现类SimpleAuthenticationInfo SimpleAuthenticationInfo simpleAuthenticationInfo = new SimpleAuthenticationInfo( user, //注意这里第一个参数就是doGetAuthorizationInfo方法中principalCollection.getPrimaryPrincipal()得到的值 accessToken, this.getName() ); return simpleAuthenticationInfo; } //授权 @Override protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principalCollection) { //认证通过后,接下来开始授权操作 //1、从 PrincipalCollection中获取登录用户的信息 System.out.println("*******principalCollection.getPrimaryPrincipal***"+principalCollection.getPrimaryPrincipal()); User user = (User)principalCollection.getPrimaryPrincipal(); //2、新建一个授权对象 SimpleAuthorizationInfo simpleAuthorizationInfo = new SimpleAuthorizationInfo(); //3、遍历用户,并添加角色和权限 for(Role role:user.getRoles()){ simpleAuthorizationInfo.addRole(role.getRoleName()); for(Permission permission:role.getPermissions()){ simpleAuthorizationInfo.addStringPermission(permission.getPermissionName()); } } return simpleAuthorizationInfo; } }
自定义Filter 在auth包内新建一个MyAuthFilter类,让它继承AuthenticatingFilter类,相应的代码如下所示:
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 73 74 75 76 77 @Component public class MyAuthFilter extends AuthenticatingFilter { /** * 定义jackson对象 * */ private static final ObjectMapper MAPPER = new ObjectMapper(); /** * 生成自定义token * */ @Override protected AuthenticationToken createToken(ServletRequest request, ServletResponse response) { //获取请求token String token = TokenUtil.getRequestToken((HttpServletRequest) request); return new MyAuthToken(token); } /** * 第一步,所有请求全部拒绝访问 * */ @Override protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) { if(((HttpServletRequest)request).getMethod().equals(RequestMethod.OPTIONS.name())){ return true; } return false; } /** * 第二步,拒绝访问的请求会调用onAccessDenied方法 * onAccessDenied方法先获取token,再调用executeLogin方法 * */ @Override protected boolean onAccessDenied(ServletRequest request, ServletResponse response) throws Exception { //获取请求中的token,如果token不存在则直接返回 String token = TokenUtil.getRequestToken((HttpServletRequest) request); if (StringUtils.isBlank(token)) { HttpServletResponse httpResponse = (HttpServletResponse) response; httpResponse.setHeader("Access-Control-Allow-Credentials", "true"); httpResponse.setHeader("Access-Control-Allow-Origin", HttpContextUtil.getOrigin()); httpResponse.setCharacterEncoding("UTF-8"); Map<String, Object> map = new HashMap<>(); map.put("status", 403); map.put("msg", "请先登录"); String json = MAPPER.writeValueAsString(map); httpResponse.getWriter().print(json); return false; } return executeLogin(request, response); } /** * token失效时候调用 * */ @Override protected boolean onLoginFailure(AuthenticationToken token, AuthenticationException e, ServletRequest request, ServletResponse response) { HttpServletResponse httpServletResponse =(HttpServletResponse) response; httpServletResponse.setContentType("application/json;charset=utf-8"); httpServletResponse.setHeader("Access-Control-Allow-Credentials", "true"); httpServletResponse.setHeader("Access-Control-Allow-Origin", HttpContextUtil.getOrigin()); httpServletResponse.setCharacterEncoding("UTF-8"); try { //处理登录失败的异常 Throwable throwable = e.getCause() == null ? e : e.getCause(); Map<String, Object> map = new HashMap<>(); map.put("status", 403); map.put("msg", "登录凭证已失效,请重新登录"); String json = MAPPER.writeValueAsString(map); httpServletResponse.getWriter().print(json); } catch (IOException e1) { } return false; } }
这里重写了createToken、isAccessAllowed、onAccessDenied和onLoginFailure方法,其中createToken是用来生成自定义token,注意这个token是从后端生成的,它存在于请求头中,因此需要开发者需要定义一个从请求中获取token的工具类。
isAccessAllowed()
方法用于将所有请求全部拒绝访问,这是第一步,之后拒绝访问的请求会调用onAccessDenied方法,而这个onAccessDenied方法会先获取token,再调用executeLogin方法。注意这里需要获取到请求中的Origin,因此也需要定义一个从请求中获取Origin的工具类。当然也可能需要获取请求中的Domain,因此这个获取Domain的工具类也顺便创建一下。
定义获取token的工具类 新建一个common包,并在里面新建utils,接着在utils包内新建一个TokenUtil类,其中的代码如下所示:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 /** * token工具类 * */ public class TokenUtil { /** * 获取请求的token * */ public static String getRequestToken(HttpServletRequest httpServletRequest){ //从header中获取token String token = httpServletRequest.getHeader("token"); //判断header中是否存在token if(StringUtils.isBlank(token)){ //不存在token,则从请求参数中获取token token= httpServletRequest.getParameter("token"); } return token; } }
定义获取Origin和Domain的工具类 在utils包内新建一个HttpContextUtil类,其中的代码如下所示:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 public class HttpContextUtil { public static HttpServletRequest getHttpServletRequest(){ return ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest(); } public static String getDomain(){ HttpServletRequest httpServletRequest = getHttpServletRequest(); StringBuffer url = httpServletRequest.getRequestURL(); return url.delete(url.length() - httpServletRequest.getRequestURI().length(), url.length()).toString(); } public static String getOrigin(){ HttpServletRequest httpServletRequest = getHttpServletRequest(); return httpServletRequest.getHeader("Origin"); } }
Shiro配置 新建config包,并在其中创建一个ShiroConfig类,其中的代码如下所示:
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 73 74 75 76 77 78 79 80 81 82 83 84 85 86 @Configuration public class ShiroConfig { /** * 返回自定义MyShiroRealm * */ @Bean public MyShiroRealm myShiroRealm(){ System.out.println("******myShiroRealm******"); MyShiroRealm myShiroRealm = new MyShiroRealm(); return myShiroRealm; } /** * 返回SecurityManager对象 * */ @Bean public SecurityManager securityManager(){ System.out.println("******securityManager******"); DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager(); //将自定义的Realm注册进去 securityManager.setRealm(myShiroRealm()); securityManager.setRememberMeManager(null); return securityManager; } @Bean("shiroFilter") public ShiroFilterFactoryBean shiroFilter(SecurityManager securityManager){ System.out.println("******shiroFilter******"); ShiroFilterFactoryBean shiroFilterFactoryBean = new ShiroFilterFactoryBean(); shiroFilterFactoryBean.setSecurityManager(securityManager); //设置过滤器 Map<String,Filter> filters = new HashMap<>(); filters.put("auth",new MyAuthFilter()); shiroFilterFactoryBean.setFilters(filters); //设置拦截器 Map<String,String> filterChainDefinitionMap = new LinkedHashMap<>(); //配置不会被拦截的链接,此时会按照顺序进行判断 filterChainDefinitionMap.put("/webjars/**", "anon"); filterChainDefinitionMap.put("/druid/**", "anon"); filterChainDefinitionMap.put("/sys/login", "anon"); filterChainDefinitionMap.put("/swagger/**", "anon"); filterChainDefinitionMap.put("/v2/api-docs", "anon"); filterChainDefinitionMap.put("/swagger-ui.html", "anon"); filterChainDefinitionMap.put("/swagger-resources/**", "anon"); filterChainDefinitionMap.put("/doc.html", "anon"); // 除了以上路径,其他都需要权限验证 filterChainDefinitionMap.put("/**", "auth"); shiroFilterFactoryBean.setFilterChainDefinitionMap(filterChainDefinitionMap); return shiroFilterFactoryBean; } /** * 配置SecurityManager的生命周期处理器 */ @Bean("lifecycleBeanPostProcessor") public LifecycleBeanPostProcessor lifecycleBeanPostProcessor() { return new LifecycleBeanPostProcessor(); } /** * 开启Shiro注解支持 * 这样就可以使用@RequiresRoles和@RequirePermissions */ @Bean public DefaultAdvisorAutoProxyCreator advisorAutoProxyCreator(){ System.out.println("******DefaultAdvisorAutoProxyCreator******"); DefaultAdvisorAutoProxyCreator advisorAutoProxyCreator = new DefaultAdvisorAutoProxyCreator(); advisorAutoProxyCreator.setProxyTargetClass(true); return advisorAutoProxyCreator; } /** * 开启Shiro AOP注解支持 */ @Bean public AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor(SecurityManager securityManager) { System.out.println("******AuthorizationAttributeSourceAdvisor******"); AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor = new AuthorizationAttributeSourceAdvisor(); authorizationAttributeSourceAdvisor.setSecurityManager(securityManager); return authorizationAttributeSourceAdvisor; } }
Swagger配置 在config包内创建一个SwaggerConfig类,其中的代码如下所示:
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 @Configuration @EnableSwagger2 public class SwaggerConfig extends WebMvcConfigurationSupport { //是否开启swagger,正式环境一般是需要关闭的,可根据springboot的多环境配置进行设置 @Value(value = "${swagger.enabled}") Boolean swaggerEnabled; @Bean Docket docket(){ return new Docket(DocumentationType.SWAGGER_2) //是否开启 .enable(swaggerEnabled).select() //扫描的路径包 .apis(RequestHandlerSelectors.basePackage("com.envy.shirofontback")) // 指定路径处理PathSelectors.any()代表所有的路径 .paths(PathSelectors.any()) .build().apiInfo( new ApiInfoBuilder().description("SpringBoot+Shiro搭建前后端分离的权限系统接口测试文档").contact( new Contact("余思博客","https://github.com/envythink","envyzhan@aliyun.com")) .version("v1.0") .title("API测试文档") .license("Apache2.0") .licenseUrl("http://www.apache.org/licenses/LICENSe-2.0") .build() ); } @Override protected void addResourceHandlers(ResourceHandlerRegistry registry) { // 解决静态资源无法访问 registry.addResourceHandler("/**") .addResourceLocations("classpath:/static/"); // 解决swagger无法访问 registry.addResourceHandler("/swagger-ui.html") .addResourceLocations("classpath:/META-INF/resources/"); // 解决swagger的js文件无法访问 registry.addResourceHandler("/webjars/**") .addResourceLocations("classpath:/META-INF/resources/webjars/"); } }
创建DTO层 这里我们在登录的时候对用户名和密码进行校验,因此需要定义一个LoginDTO对象。新建dto包,并在dto包内创建一个LoginDTO类,其中的代码如下所示:
1 2 3 4 5 6 7 8 9 10 /** * 登录传输类 * */ @Data public class LoginDTO { @NotBlank(message = "用户名不能为空") private String username; @NotBlank(message = "密码不能为空") private String password; }
创建Controller层 新建controller包,并在其中创建UserController类,其中的代码如下所示:
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 @Api(tags = "用户",value = "用户Controller") @RestController public class UserController { @Autowired private ShiroService shiroService; /** * 用户登录 * */ @ApiOperation(value = "登录",notes = "参数:用户名 密码") @PostMapping("/sys/login") public Map<String,Object> login(@RequestBody @Validated LoginDTO loginDTO, BindingResult bindingResult){ Map<String,Object> map = new HashMap<>(); if(bindingResult.hasErrors()){ map.put("status",400); map.put("message",bindingResult.getFieldError().getDefaultMessage()); return map; }else{ String username = loginDTO.getUsername(); String password = loginDTO.getPassword(); //查询用户信息是否存在 User user = shiroService.findByUsername(username); if(user==null || !user.getPassword().equals(password)){ //账户,密码错误 map.put("status",400); map.put("message","账户或者密码错误"); }else{ //用户信息存在,则生成token信息 map = shiroService.createToken(user.getUserId()); map.put("status",200); map.put("message","登录成功"); } return map; } } /** * 用户退出 * */ @ApiOperation(value = "退出",notes = "参数:token") @PostMapping("/sys/logout") public Map<String,Object> logout(@RequestHeader("token") String token){ Map<String,Object> map = new HashMap<>(); shiroService.logout(token); map.put("status",200); map.put("message","退出成功"); return map; } }
再来创建一个TestController类,其中的代码如下所示:
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 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 @Api(tags = "测试",value = "测试Controller") @RestController public class TestController { /** * 模拟添加用户 * */ @RequiresPermissions({"user:add"}) @PostMapping("/save") public Map<String,Object> save(@RequestHeader("token")String token){ System.out.println("******save******"); Map<String,Object> map = new HashMap<>(); map.put("status",200); map.put("msg","当前用户具有添加用户的权限"); return map; } /** * 模拟删除用户 * */ @RequiresPermissions({"user:delete"}) @DeleteMapping("/delete") public Map<String,Object> delete(@RequestHeader("token")String token){ System.out.println("******delete******"); Map<String,Object> map = new HashMap<>(); map.put("status",200); map.put("msg","当前用户具有删除用户的权限"); return map; } /** * 模拟修改用户 * */ @RequiresPermissions({"user:edit"}) @PostMapping("/update") public Map<String,Object> update(@RequestHeader("token")String token){ System.out.println("******update******"); Map<String,Object> map = new HashMap<>(); map.put("status",200); map.put("msg","当前用户具有修改用户的权限"); return map; } /** * 模拟查询用户 * */ @RequiresPermissions({"user:query"}) @GetMapping("/select") public Map<String,Object> select(@RequestHeader("token")String token){ System.out.println("******select******"); Map<String,Object> map = new HashMap<>(); map.put("status",200); map.put("msg","当前用户具有查询用户的权限"); return map; } /** * 判断当前用户是否具有开发人员角色 * */ @RequiresRoles({"develop"}) @GetMapping("/develop") public Map<String,Object> develop(@RequestHeader("token")String token){ System.out.println("******develop******"); Map<String,Object> map = new HashMap<>(); map.put("status",200); map.put("msg","当前用户具有开发人员角色"); return map; } /** * 判断当前用户是否具有开发测试角色 * */ @RequiresRoles({"test"}) @GetMapping("/test") public Map<String,Object> test(@RequestHeader("token")String token){ System.out.println("******test******"); Map<String,Object> map = new HashMap<>(); map.put("status",200); map.put("msg","当前用户具有测试人员角色"); return map; } /** * 判断当前用户是否具有运维人员角色 * */ @RequiresRoles({"operate"}) @GetMapping("/operate") public Map<String,Object> operate(@RequestHeader("token")String token){ System.out.println("******operate******"); Map<String,Object> map = new HashMap<>(); map.put("status",200); map.put("msg","当前用户具有运维人员角色"); return map; } }
自定义异常处理类 在common包中新建handler包,之后在handler包内新建MyExceptionHandler类,这个类用于处理全局异常,如下所示:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 /** * 全局异常处理器 */ @ControllerAdvice public class MyExceptionHandler { @ExceptionHandler(value = AuthorizationException.class) public Map<String, String> handleException(AuthorizationException e) { //e.printStackTrace(); Map<String, String> result = new HashMap<>(); result.put("status", "400"); //获取错误中中括号的内容 String message = e.getMessage(); String msg=message.substring(message.indexOf("[")+1,message.indexOf("]")); //判断是角色错误还是权限错误 if (message.contains("role")) { result.put("msg", "对不起,您没有" + msg + "角色"); } else if (message.contains("permission")) { result.put("msg", "对不起,您没有" + msg + "权限"); } else { result.put("msg", "对不起,您的权限有误"); } return result; } }
详解校验流程 下图详细说明了校验流程:
第一步 ,当前端用户发起请求,请求会进入MyAuthFilter类的 isAccessAllowed()
方法中,除了OPTION方法,其余都拦截:
第二步 ,拦截之后的请求会进入MyAuthFilter类的onAccessDenied()
方法中,这里获取token后判断token是否为空,如果为空,则说明请求未携带token,状态直接返回403,信息为用户未登录,这样流程就结束了;如果不为空,即请求携带了token则进入第三步调用executeLogin方法:
第三步 ,调用executeLogin方法,从源码可以知道它首先调用createToken方法来生成token:
而我们自己在MyAuthFilter类中继承了AuthenticatingFilter类,并重写了createToken方法:
这样就生成了我们自定义的MyAuthToken对象,这个对象中除了有username和password,还有token,且它重写了UsernamePasswordToken类中的getPrincipal
和getCredentials
方法。
第四步 ,请求会来到MyShiroRealm类中,首先是进行认证,那么会进入到doGetAuthenticationInfo方法中,这个方法首先获取请求中的token,之后使用token去数据库中查询是否存在对应的token信息,如果不存在,那么就进入第五步;如果存在则进入第六步:
第五步 ,由于无法通过请求中的token在数据库中找到对应的Token信息,因此请求就会到MyAuthFilter类的onLoginFailure方法中,状态返回403,消息返回“登录凭证已失效,请重新登录”,并结束认证过程:
第六步 ,通过请求中的token可以在数据库中找到对应的Token信息,因此请求会到MyShiroRealm类中doGetAuthorizationInfo方法中,这说明用户认证通过,接下来就是授权过程。
首先获取当前用户信息,然后遍历当前用户的角色和权限信息,之后调用底层的代码来进行角色和权限验证,如果当前用户具有对应的角色和权限,那么就进入Controller中对应的方法中,否则就抛出异常,并结束授权过程:
启动并测试 启动ShiroFontbackApplication
项目入口类,然后在浏览器中访问http://localhost:9090/shiro/swagger-ui.html
链接时,页面会显示如下信息:
之后查看这个两个Controller中的方法,如下所示:
接下来开始测试,依次如下所示: (1)登录成功时:
(2)登录失败时:
(3)当前用户具有某个角色时:
(4)当前用户没有某个角色时:
(5)当前用户具有某个权限时:
(6)当前用户没有某个权限时:
(7)当前用户成功退出时:
请注意,由于此处token频繁在客户端和服务器端传输,因此可能会造成token劫持攻击,如果用户对这方面有较高的安全性要求,可以采取每访问一次接口,更新一次token的方式。同时由于这里只是演示,就使用Mysql来存储token,而在实际开发过程中都是使用Redis来缓存token,且Redis自带了过期时间,非常适合token的存储。
ok那么本篇关于SpringBoot+Shiro搭建前后端分离的权限系统的学习就到此为止,后续学习其他内容。
参考文章:Springboot +Shiro前后端分离式权限管理系统