写在前面
前面学习的都是通过模拟数据库这一方式来学习,在实际开发过程中都是直接连接数据库,因此本篇将搭建一个使用Shiro框架实现的权限校验系统。
新建SpringBoot项目
使用spring Initializr
构建工具构建一个SpringBoot的Web应用,名称为envy-shiro
,然后在pom.xml文件中添加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
| <dependencies> <!--添加Shiro依赖--> <dependency> <groupId>org.apache.shiro</groupId> <artifactId>shiro-spring</artifactId> <version>1.4.0</version> </dependency>
<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> <!--添加thymeleaf-extras-shiro依赖--> <dependency> <groupId>com.github.theborakompanioni</groupId> <artifactId>thymeleaf-extras-shiro</artifactId> <version>2.0.0</version> </dependency> <!--添加数据库驱动依赖--> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> </dependency> <!--添加数据库连接池依赖--> <dependency> <groupId>com.alibaba</groupId> <artifactId>druid</artifactId> <version>1.0.25</version> </dependency> <!--添加JPA依赖--> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-jpa</artifactId> </dependency> <!--添加lombok依赖--> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> </dependency> </dependencies>
|
特别注意这里不需要添加spring-boot-starter-web
依赖,因为shiro-spring-boot-web-starter
中已经依赖了spring-boot-starter-web
依赖。同时本案例使用了Thymeleaf模板,因此需要添加Thymeleaf依赖,另外为了在Thymeleaf中使用shiro标签,因此需要引入thymeleaf-extras-shiro
依赖。
Thymeleaf基本配置
在application.properties
配置文件中配置Thymeleaf的基本信息,代码如下所示:
1 2 3 4 5 6 7 8 9 10 11 12 13
| # thymeleaf配置 # 模式 spring.thymeleaf.mode=HTML # 字符集 spring.thymeleaf.encoding=UTF-8 # 内容类型 spring.thymeleaf.servlet.content-type=text/html # 关闭缓存 spring.thymeleaf.cache=false # 文件路径 spring.thymeleaf.prefix=classpath:/templates/ # 文件后缀 spring.thymeleaf.suffix=.html
|
DataSource配置
在application.properties
配置文件中配置DataSource的基本信息,代码如下所示:
1 2 3 4 5 6 7 8 9 10 11
| # 数据库相关配置 spring.datasource.type=com.alibaba.druid.pool.DruidDataSource spring.datasource.url=jdbc:mysql://127.0.0.1:3306/envyshiro?useUnicode=true&characterEncoding=UTF-8&serverTimezone=UTC spring.datasource.username=root spring.datasource.password=envy123
# JPA相关配置 spring.jpa.show-sql=true spring.jpa.database=mysql spring.jpa.hibernate.ddl-auto=update spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQL57Dialect
|
创建实体类
新建pojo包,并在其中创建三个实体类:UserInfo、SysRole和SysPermission,各个实体类中的代码如下所示:
首先看一下用户信息表:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| //UserInfo.java
@Entity @Data public class UserInfo { @Id @GeneratedValue private Long id; //主键
@Column(unique = true) private String username; //登录账户,注意值唯一 private String name; //名称,(匿名或者真实姓名) private String password; //密码 private String salt; //密码加密的盐
@JsonIgnoreProperties(value = {"userInfos"}) @ManyToMany(fetch = FetchType.EAGER) //立即从数据库中记载数据 @JoinTable(name = "SysUserRole",joinColumns = @JoinColumn(name = "uid"),inverseJoinColumns = @JoinColumn(name = "roleId")) private List<SysRole> roles; //一个用户具有多个角色 }
|
再来看一下角色信息表:
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
| //SysRole.java
@Entity @Data public class SysRole { @Id @GeneratedValue private Long id; //主键
private String name; //角色名称,如admin/user
private String description; //角色描述,注意这是用于UI显示用的
/** * 角色--权限多对多关系 * */ @JsonIgnoreProperties(value = {"roles"}) @ManyToMany(fetch = FetchType.EAGER) //立即从数据库中记载数据 @JoinTable(name = "SysRolePermission",joinColumns = {@JoinColumn(name = "roleId")},inverseJoinColumns = {@JoinColumn(name = "permissionId")}) private List<SysPermission> permissions;
/** * 用户--角色多对多关系 * */ @JsonIgnoreProperties(value = {"roles"}) @ManyToMany @JoinTable(name = "SysUserRole",joinColumns = {@JoinColumn(name = "roleId")},inverseJoinColumns = {@JoinColumn(name = "uid")}) private List<UserInfo> userInfos; }
|
再来看一下权限信息表:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| //SysPermission.java
@Entity @Data public class SysPermission {
@Id @GeneratedValue private Long id; //主键 private String name; //权限名称,如user:add/user:delete private String description; //权限描述,注意这是用于UI显示用的 private String url; //权限地址
@JsonIgnoreProperties(value = {"permissions"}) @ManyToMany @JoinTable(name = "SysRolePermission",joinColumns = {@JoinColumn(name = "permissionId")},inverseJoinColumns = {@JoinColumn(name = "roleId")}) private List<SysRole> roles; //一个权限可以被多个角色使用 }
|
请注意上面有一个坑,就是当使用RESTful风格的API与前端进行JSON信息交互的时候,可能会存在多对多的无限循环。举个例子,当我们想要返回一个用户信息给前端时,由于一个用户拥有多个角色,而一个角色又拥有多个权限,权限和角色也是多对多的关系,这就形成了“查用户→查角色→查权限→查角色→查用户… ”这样的无限循环,导致传输错误,所以用户可以根据这样的逻辑在每一个实体类返回JSON时使用了一个@JsonIgnoreProperties
注解,来排除自己对自己无限引用的过程,即打断这样的无限循环。
插入数据
在数据库中新建envyshiro数据库,然后启动项目,此时会在数据库中自动生成user_info(用户信息表)、sys_role(角色表)、sys_permission(权限表)、sys_user_role(用户角色表)和sys_role_permission(角色权限表),为了后续测试需要,这里先往这些表中插入一些测试数据,如下所示:
1 2 3 4 5 6 7
| use envyshiro;
INSERT INTO `user_info` (`id`,`name`,`password`,`salt`,`username`) VALUES (1, '管理员','cd874e3957efc155e0d4380866e024be', 'gakMCJgWgfIGTimgD2l/bw==', 'envy'); INSERT INTO `sys_permission` (`id`,`description`,`name`,`url`) VALUES (1,'查询用户','userInfo:view','/userList'),(2,'增加用户','userInfo:add','/userAdd'),(3,'删除用户','userInfo:delete','/userDelete'); INSERT INTO `sys_role` (`id`,`description`,`name`) VALUES (1,'管理员','admin'); INSERT INTO `sys_role_permission` (`permission_id`,`role_id`) VALUES (1,1),(2,1); INSERT INTO `sys_user_role` (`role_id`,`uid`) VALUES (1,1);
|
创建Dao层
新建dao包,并在其中创建一个接口文件UserInfoDao:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| public interface UserInfoDao extends JpaRepository<UserInfo,Long> { /** * 通过用户名查找用户信息 * */ public UserInfo findByUsername(String username);
/** * 添加用户 * */ public UserInfo save(UserInfo userInfo);
/** * 删除用户 * */ public UserInfo deleteUserInfoById(Long id); }
|
创建Service层
新建service包,并在其中创建一个接口文件UserInfoService:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| public interface UserInfoService { /** * 通过用户名查找用户信息 * */ public UserInfo findByUsername(String username);
/** * 添加用户 * */ public UserInfo save(UserInfo userInfo);
/** * 删除用户 * */ public UserInfo deleteUserInfoById(Long id); }
|
之后在service包内新建一个impl包,并在impl包内新建一个UserInfoServiceImpl类:
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
| @Service public class UserInfoServiceImpl implements UserInfoService { @Autowired private UserInfoDao userInfoDao;
/** * 通过用户名查找用户信息 * */ @Override public UserInfo findByUsername(String username) { return userInfoDao.findByUsername(username); }
/** * 添加用户 * */ @Override public UserInfo save(UserInfo userInfo){ return userInfoDao.save(userInfo); }
/** * 删除用户 * */ @Override public UserInfo deleteUserInfoById(Long id){ return userInfoDao.deleteUserInfoById(id); } }
|
自定义Realm
新建realm包,并在其中创建一个MyShiroRealm类,其中的代码如下所示:
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
| public class MyShiroRealm extends AuthorizingRealm {
@Autowired private UserInfoService userInfoService;
//认证 @Override protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authenticationToken) throws AuthenticationException { //1、从主体传过来的认证信息中获取用户名 String username = (String) authenticationToken.getPrincipal(); //2、通过用户名去数据库中查询用户信息 //在实际工作中会使用缓存,不会每次都去数据库中查询,当然Shiro默认的缓存时间间隔为2分钟,即2分钟内不会重复执行该方法 UserInfo userInfo = userInfoService.findByUsername(username); if(null==userInfo){ return null; } //3、新建一个认证对象 //验证通过,认证信息中存放账号密码, getName()用于获取当前Realm的继承方法,通常返回当前类名MyDatabaseReal SimpleAuthenticationInfo simpleAuthenticationInfo = new SimpleAuthenticationInfo( userInfo.getUsername(), //登录账户,注意值唯一,,注意这个就是后续doGetAuthorizationInfo中中的principalCollection对象 userInfo.getPassword(), //密码 ByteSource.Util.bytes(userInfo.getSalt()), //盐=密码+盐 getName()); //realm的值,此处为自定义的MyShiroRealm return simpleAuthenticationInfo; }
//授权 @Override protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principalCollection) { //认证通过后,接下来开始授权操作 System.out.println("********principalCollection.getPrimaryPrincipal();**********"); //1、查询当前用户信息 String username = (String) principalCollection.getPrimaryPrincipal(); UserInfo userInfo = userInfoService.findByUsername(username); //2、新建一个授权对象 SimpleAuthorizationInfo simpleAuthorizationInfo = new SimpleAuthorizationInfo(); //3、遍历并赋值 for(SysRole role:userInfo.getRoles()){ simpleAuthorizationInfo.addRole(role.getName()); for(SysPermission permission:role.getPermissions()){ simpleAuthorizationInfo.addStringPermission(permission.getName()); } } return simpleAuthorizationInfo; } }
|
请注意这里doGetAuthenticationInfo方法中的authenticationToken.getPrincipal()
方法得到的其实是一个Object类型的username ,之后返回一个SimpleAuthenticationInfo对象,注意它里面的第一个参数是username,而不是name,这一点需要注意,同时后续doGetAuthorizationInfo方法中的principalCollection.getPrimaryPrincipal()
方法返回的对象就是这个username。
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
| @Configuration public class ShiroConfig {
/** * 密码加密设置,注意密码加密校验由SimpleAuthenticationInfo负责 * */ @Bean public HashedCredentialsMatcher hashedCredentialsMatcher(){ System.out.println("******hashedCredentialsMatcher******"); HashedCredentialsMatcher matcher = new HashedCredentialsMatcher(); matcher.setHashAlgorithmName("md5"); //设置加密算法,这里使用MD5算法 matcher.setHashIterations(3); //加密次数,这里设置三次,其实相当于md5(md5(md5())) return matcher; }
/** * 返回自定义MyShiroRealm * */ @Bean public MyShiroRealm myShiroRealm(){ System.out.println("******myShiroRealm******"); MyShiroRealm myShiroRealm = new MyShiroRealm(); myShiroRealm.setCredentialsMatcher(hashedCredentialsMatcher()); return myShiroRealm; }
/** * 返回SecurityManager对象 * */ @Bean public SecurityManager securityManager(){ System.out.println("******securityManager******"); DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager(); securityManager.setRealm(myShiroRealm()); return securityManager; }
@Bean public ShiroFilterFactoryBean shiroFilter(SecurityManager securityManager){ System.out.println("******shiroFilter******"); ShiroFilterFactoryBean shiroFilterFactoryBean = new ShiroFilterFactoryBean(); shiroFilterFactoryBean.setSecurityManager(securityManager); //设置拦截器 Map<String,String> filterChainDefinitionMap = new LinkedHashMap<>(); //配置不会被拦截的链接,此时会按照顺序进行判断 //anon是匿名访问,authc是通过认证后才能访问 filterChainDefinitionMap.put("/static/**","anon"); //配置退出过滤器,具体已经由Shiro实现了 filterChainDefinitionMap.put("/logout","logout"); //配置过滤链,它会从上到下顺序执行,因此一般将/**放在最下面 filterChainDefinitionMap.put("/**","authc");
//如果不设置,则默认会自动寻找Web工程根目录下的login.html页面 shiroFilterFactoryBean.setLoginUrl("/login"); //设置登录成功后需要跳转的链接 shiroFilterFactoryBean.setSuccessUrl("/index");
//设置未授权页面 shiroFilterFactoryBean.setUnauthorizedUrl("/unauthorized"); shiroFilterFactoryBean.setFilterChainDefinitionMap(filterChainDefinitionMap); return shiroFilterFactoryBean; }
/** * 开启Shiro注解支持 * 这样就可以使用@RequiresRoles和@RequirePermissions */ @Bean public DefaultAdvisorAutoProxyCreator advisorAutoProxyCreator(){ DefaultAdvisorAutoProxyCreator advisorAutoProxyCreator = new DefaultAdvisorAutoProxyCreator(); advisorAutoProxyCreator.setProxyTargetClass(true); return advisorAutoProxyCreator; }
/** * 开启Shiro AOP注解支持 */ @Bean public AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor(SecurityManager securityManager) { AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor = new AuthorizationAttributeSourceAdvisor(); authorizationAttributeSourceAdvisor.setSecurityManager(securityManager); return authorizationAttributeSourceAdvisor; } }
|
由于Shiro是通过Filter过滤器来实现权限控制,如果你之前使用过Servlet,那么你就知道过滤器是采用URL规则来进行访问控制的,因此Shiro框架也不例外,它也是通过URL规则来进行过滤和权限校验,开发者需要提供一个shiroFilter方法,里面配置URL规则和访问权限。
这里定义了一个filterChainDefinitionMap
对象,里面按照插入的顺序来依次进行过滤。关于Filter Chain,它有几点注意事项:(1)一个URL可以配置多个Filter,多个Filter之间使用逗号进行分隔;(2)注意当一个URL设置多个Filter时,只有所有Filter都通过,才认为该URL可以访问;(3)一些过滤器可以指定部分参数,如roles和perms。
下表显示Shiro框架内置的FilterChain信息:
Filter Name |
Class |
anon |
org.apache.shiro.web.filter.authc.AnonymousFilter |
authc |
org.apache.shiro.web.filter.authc.FormAuthenticationFilter |
authcBasic |
org.apache.shiro.web.filter.authc.BasicHttpAuthenticationFilter |
perms |
org.apache.shiro.web.filter.authc.PermissionsAuthorizationFilter |
port |
org.apache.shiro.web.filter.authc.PortFilter |
rest |
org.apache.shiro.web.filter.authc.HttpMethodPermissionFilter |
roles |
org.apache.shiro.web.filter.authc.RolesAuthorizationFilter |
ssl |
org.apache.shiro.web.filter.authc.SslFilter |
user |
org.apache.shiro.web.filter.authc.UserFilter |
请注意,上面FilterChain中的anon表示可以匿名访问,即不需要验证就能直接访问;authc表示需要认证之后方可访问;user表示记住我或者认证通过后方可访问。
创建Controller层
新建controller包,并在其中创建UserInfoController类,其中的代码如下所示:
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
| @RestController public class UserInfoController {
@Autowired private UserInfoService userInfoService;
@GetMapping("/userList") @RequiresPermissions("userInfo:view") public UserInfo findUserInfoByUserName(@RequestParam("username") String username){ return userInfoService.findByUsername(username); }
@PostMapping("/userAdd") @RequiresPermissions("userInfo:add") public String addUserInfo(){ UserInfo userInfo = new UserInfo(); userInfoService.save(userInfo); return "添加用户成功"; }
@DeleteMapping("/userDelete") @RequiresPermissions("userInfo:delete") public String deleteUserInfo(@RequestParam("id")Long id){ userInfoService.deleteUserInfoById(id); return "删除用户成功"; } }
|
注意这里面的addUserInfo和deleteUserInfo方法的具体逻辑并没有实现,这里只是模拟从数据库中添加和删除用户的操作。@RequiresPermissions("userInfo:add")
中的内容其实就是之前在MyShiroRealm类中doGetAuthorizationInfo方法内给simpleAuthorizationInfo对象赋予的权限,可以看到这里无论是role还是permission,设置的都是Name,因此在@RequiresPermissions
和@RequiresRoles
中都必须使用permission和role的name属性。
再来创建一个HomoController类,其中的代码如下所示:
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
| @Controller public class HomoController {
@GetMapping({"/","/index"}) public String index(){ return "index"; }
@RequestMapping("/login") public String login(HttpServletRequest request, Map<String, Object> map) throws Exception{ System.out.println("******HomoController.login()******"); // 登录失败从request中获取shiro处理的异常信息,shiroLoginFailure:就是shiro异常类的全类名. String exception = (String) request.getAttribute("shiroLoginFailure"); System.out.println("exception=" + exception); String error = ""; if (exception != null) { if (UnknownAccountException.class.getName().equals(exception)) { error = "账号不存在:"; } else if (IncorrectCredentialsException.class.getName().equals(exception)) { error = "密码不正确:"; } else { error = "其他未知错误"+exception; } } map.put("error", error); // 请注意这里的login方法不处理登录成功,而是由Shiro进行处理 // 其实就是之前介绍的调用SecurityUtils.getSubject()和subject.isAuthenticated()方法进行验证 return "/login"; }
@GetMapping("/unauthorized") public String unauthorized(){ System.out.println("*****验证不通过****"); return "unauthorized"; } }
|
这里面就是各种页面和登录时异常信息的显示配置,很好理解。
创建页面
在templates目录下新建三个页面,第一个是index.html,其中的代码如下所示:
1 2 3 4 5 6 7 8 9 10
| <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <title>首页</title> </head> <body> <h3>这是首页</h3> </body> </html>
|
第二个是login.html,其中的代码如下所示:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| <!DOCTYPE html> <html lang="en" xmlns:th="http://www.thymeleaf.org"> <head> <meta charset="UTF-8"> <title>登录页面</title> </head> <body> <form action="/login" method="post"> 用户名:<input type="text" name="username" value="envy"><br> 密码:<input type="password" name="password" value="1234"><br> <div th:text="${error}"></div> <input type="submit" value="登录"> </form> </body> </html>
|
第三个是unauthorized.html,其中的代码如下所示:
1 2 3 4 5 6 7 8 9 10
| <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <title>unauthorized</title> </head> <body> <h3>这是unauthorized页面</h3> </body> </html>
|
启动项目
启动EnvyShiroApplication项目入口类,然后在浏览器中访问http://localhost:8080/userList?username=envy
链接时,页面会跳转到登录页面,即地址变为http://localhost:8080/login
,之后输入正确的账户信息,它就会自动调用MyShiroRealm类中定义的AuthenticationInfo方法,之后就能看到诸如如下的信息:
登录之后,由于envy用户具有查询、增加和删除用户的权限,因此/userAdd
和/userDelete
接口都是可以访问的,只是没有实现对应的逻辑罢了。
参考文章:Shiro安全框架【快速入门】就这一篇!
请注意这篇是后续学习的基础,需要仔细学习,后续会对这篇涉及到的一些知识进行详细学习。