写在前面

前面学习的都是通过模拟数据库这一方式来学习,在实际开发过程中都是直接连接数据库,因此本篇将搭建一个使用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安全框架【快速入门】就这一篇!

请注意这篇是后续学习的基础,需要仔细学习,后续会对这篇涉及到的一些知识进行详细学习。