抱歉,您的浏览器无法访问本站
本页面需要浏览器支持(启用)JavaScript
了解详情 >

SpringBoot整合Shiro

Shiro简介

​ Shiro是Apache下的一个开源项目,我们称之为Apache Shiro。它是一个很易用与Java项目的的安全框架,提供了认证、授权、加密、会话管理,与Spring Security 一样都是做一个权限的安全框架,但是与Spring Security 相比,在于 Shiro 使用了比较简单易懂易于使用的授权方式。shiro属于轻量级框架,相对于security简单的多,也没有security那么复杂。所以我这里也是简单介绍一下shiro的使用。

Shiro的架构

Authentication(认证), Authorization(授权), Session Management(会话管理), Cryptography(加密)被 Shiro 框架的开发团队称之为应用安全的四大基石。

● Authentication(认证):用户身份识别,通常被称为用户“登录”
● Authorization(授权):访问控制。比如某个用户是否具有某个操作的使用权限。
● Session Management(会话管理):特定于用户的会话管理,甚至在非web 或 EJB 应用程序。
● Cryptography(加密):在对数据源使用加密算法加密的同时,保证易于使用。
同时Shiro还提供了其他特性来在不同的应用程序环境下使用强化以上的四大基石:
● Web支持:Shiro 提供的 web 支持 api ,可以很轻松的保护 web 应用程序的安全。
● 缓存:缓存是 Apache Shiro 保证安全操作快速、高效的重要手段。
● 并发:Apache Shiro 支持多线程应用程序的并发特性。
● 测试:支持单元测试和集成测试,确保代码和预想的一样安全。
● “Run As”:这个功能允许用户假设另一个用户的身份(在许可的前提下)。
● “Remember Me”:跨 session 记录用户的身份,只有在强制需要时才需要登录。

Shiro 的三大核心组件

● Subject其实代表的就是当前正在执行操作的用户,只不过因为“User”一般指代人,但是一个“Subject”可以是人,也可以是任何的第三方系统,服务账号等任何其他正在和当前系统交互的第三方软件系统。所有的Subject实例都被绑定到一个SecurityManager,如果你和一个Subject交互,所有的交互动作都会被转换成Subject与SecurityManager的交互。

● SecurityManager 是Shiro的核心,用于管理所有的Subject ,它主要用于协调Shiro内部各种安全组件,不过我们一般不用太关心SecurityManager,对于应用程序开发者来说,主要还是使用Subject的API来处理各种安全验证逻辑。

● Realm 这是用于连接Shiro和客户系统的用户数据的桥梁。一旦Shiro真正需要访问各种安全相关的数据(比如使用用户账户来做用户身份验证以及权限验证)时,他总是通过调用系统配置的各种Realm来读取数据。
正因为如此,Realm往往被看做是安全领域的DAO,他封装了数据源连接相关的细节,将数据以Shiro需要的格式提供给Shiro。当我们配置Shiro的时候,我们至少需要配置一个Realm来提供用户验证和权限控制方面的数据。我们可能会给SecurityManager配置多个Realm,但是不管怎样,我们至少需要配置一个。
Shiro提供了几种开箱即用的Realm来访问安全数据源,比如LDAP、关系数据库、基于ini的安全配置文件等等,如果默认提供的这几种Realm无法满足你的需求,那么你也可以编写自己的定制化的Realm插件。
和其他内部组件一样,SecurityManager决定了在Shiro中如何使用Realm来读取身份和权限方面的数据,然后组装成Subject实例。

Shiro完整架构

● Subject (org.apache.shiro.subject.Subject),如上所述.
● SecurityManager (org.apache.shiro.mgt.SecurityManager),如上所述.
● Authenticator(用户认证管理器), (org.apache.shiro.authc.Authenticator) 这个组件主要用于处理用户登录逻辑,他通过调用Realm的接口来判断当前登录的用户的身份。
用户认证策略,(org.apache.shiro.authc.pam.AuthenticationStrategy) 如果系统配置了多个Realm,则需要使用AuthenticationStrategy 来协调这些Realm以便决定一个用户登录的认证是成功还是失败。(比如,如果一个Realm验证成功了,但是其他的都失败了,怎么算?还是说都成功才算成功).
● Authorizer(权限管理器)(org.apache.shiro.authz.Authorizer)这个组件主要是用来做用户的访问控制。通俗来说就是决定用户能做什么、不能做什么。和Authenticator类似,Authorizer也知道怎么协调多个Realm数据源的数据,他有自己的一套策略。
● SessionManager(会话管理器) (org.apache.shiro.session.mgt.SessionManager) SessionManager知道如何创建会话、管理用户回话的声明周期以便在所有运行环境下都可以给用户提供一个健壮的回话管理体验。Shiro在任何运行环境下都可以在本地管理用户会话(即便没有Web或者EJB容器也可以)——这在安全管理的框架中算是独门绝技了。当然,如果当前环境中有会话管理机制(比如Servlet容器),则Shiro默认会使用该环境的会话管理机制。而如果像控制台程序这种独立的应用程序,本身没有会话管理机制,此时Shiro就会使用内部的会话管理器来给应用的开发提供一直的编程体验。SessionDAO允许用户使用任何类型的数据源来存储Session数据。
● SessionDAO (org.apache.shiro.session.mgt.eis.SessionDAO) 用于代替SessionManager执行Session相关的增删改查。这个接口允许我们将任意种类的数据存储方式引入到Session管理的基础框架中。
● CacheManager (org.apache.shiro.cache.CacheManager) CacheManager用于创建和维护一些在其他的Shiro组件中用到的Cache实例,维护这些Cache实例的生命周期。缓存用于存储那些从后端获取到的用户验证与权限控制方面的数据以提高性能,缓存是一等公民,在获取数据时,总是先从缓存中查找,如果没有再调用后端接口从其他数据源获取。Shiro允许用户使用其他更加现代的、企业级的数据源来替代内部的默认实现,以提供更高的性能和更好的用户体验。
● Cryptography 加密技术,(org.apache.shiro.crypto.*) 对于一个企业级的安全框架来说,加密算是其固有的一种特性。Shiro的crypto包中包含了一系列的易于理解和使用的加密、哈希(aka摘要)辅助类。这个包内的所有类都是经过精心设计,相比于java本身提供的那一套反人类的加密组件,Shiro提供的这套加密组件简直不要好用太多。
● Realm (org.apache.shiro.realm.Realm) 就如上文所提到的,Realm是连接Shiro和你的安全数据的桥梁。任何时候当Shiro需要执行登录或者访问控制的时候,都需要调用已经配置的Realm的接口去获取数据。一个应用程序可以配置一个或者多个Realm(最少配置一个)。

整合Shiro

使用springboot+mybatis+druid+thymleaf模板实现快速入门

前置工作

整合mybatis+durid

参考springboot 整合durid,springboot 整合mybatis

整合thymleaf

参考springboot 整合模板引擎

配置文件配置
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
# 数据库访问配置
# 主数据源
spring.datasource.type=com.alibaba.druid.pool.DruidDataSource
spring.datasource.driverClassName=com.mysql.jdbc.Driver
spring.datasource.url=jdbc:mysql://localhost:3306/test?useUnicode=true&characterEncoding=UTF-8&serverTimezone=UTC
spring.datasource.username=root
spring.datasource.password=root
######################### Druid连接池的配置信息 #################
#初始化连接大小
spring.druid.initialSize=5
#最小连接池数量
spring.druid.minIdle=5
#最大连接池数量
spring.druid.maxActive=20
#获取连接时最大等待时间,单位毫秒
spring.druid.maxWait=60000
#配置间隔多久才进行一次检测,检测需要关闭的空闲连接,单位是毫秒
spring.druid.timeBetweenEvictionRunsMillis=60000
#配置一个连接在池中最小生存的时间,单位是毫秒
spring.druid.minEvictableIdleTimeMillis=300000
#测试连接
spring.druid.validationQuery=SELECT 1 FROM DUAL
#申请连接的时候检测,建议配置为true,不影响性能,并且保证安全性
spring.druid.testWhileIdle=true
#获取连接时执行检测,建议关闭,影响性能
spring.druid.testOnBorrow=false
#归还连接时执行检测,建议关闭,影响性能
spring.druid.testOnReturn=false
#是否开启PSCache,PSCache对支持游标的数据库性能提升巨大,oracle建议开启,mysql下建议关闭
spring.druid.poolPreparedStatements=false
#开启poolPreparedStatements后生效
spring.druid.maxPoolPreparedStatementPerConnectionSize=20
#配置扩展插件,常用的插件有=>stat:监控统计 log4j:日志 wall:防御sql注入
spring.druid.filters=stat,wall,log4j
#通过connectProperties属性来打开mergeSql功能;慢SQL记录
spring.druid.connectionProperties='druid.stat.mergeSql=true;druid.stat.slowSqlMillis=5000'

spring.redis.port=6379
spring.redis.host=127.0.0.1
# redis 数据库索引(默认为0)
spring.redis.database=0
# 连接池最大连接数(使用负值表示没有限制)
spring.redis.jedis.pool.max-active=8
# 连接池最大阻塞等待时间(使用负值表示没有限制)
spring.redis.jedis.pool.max-wait=-1
# reids 最大空闲连接
spring.redis.jedis.pool.max-idle=5
# 连接池最小空闲链接
spring.redis.jedis.pool.min-idle=0
# redis 超时时间(单位毫秒)
spring.redis.timeout=10000

mybatis.mapper-locations=classpath:mapper/*.xml
mybatis.type-aliases-package=com.demo.entity

POM依赖导入

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

<dependency>
<groupId>org.apache.shiro</groupId>
<artifactId>shiro-spring</artifactId>
<version>1.5.2</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>

<dependency>
<groupId>com.github.theborakompanioni</groupId>
<artifactId>thymeleaf-extras-shiro</artifactId>
<version>2.0.0</version>
<exclusions>
<exclusion>
<groupId>org.apache.shiro</groupId>
<artifactId>shiro-core</artifactId>
</exclusion>
<exclusion>
<groupId>org.thymeleaf</groupId>
<artifactId>thymeleaf</artifactId>
</exclusion>
<exclusion>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
</exclusion>
</exclusions>
</dependency>

数据库建表

user_info.sql(用户表)
1
2
3
4
5
6
7
8
9
10
11
12
DROP TABLE IF EXISTS `user_info`;
CREATE TABLE `user_info` (
`uid` int(11) NOT NULL AUTO_INCREMENT,
`username` varchar(50) DEFAULT '' COMMENT '用户名',
`password` varchar(256) DEFAULT NULL COMMENT '登录密码',
`name` varchar(256) DEFAULT NULL COMMENT '用户真实姓名',
`id_card_num` varchar(256) DEFAULT NULL COMMENT '用户身份证号',
`state` char(1) DEFAULT '0' COMMENT '用户状态:0:正常状态,1:用户被锁定',
PRIMARY KEY (`uid`),
UNIQUE KEY `username` (`username`) USING BTREE,
UNIQUE KEY `id_card_num` (`id_card_num`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
sys_role.sql(角色表)
1
2
3
4
5
6
7
8
9
DROP TABLE IF EXISTS `sys_role`;
CREATE TABLE `sys_role` (
`id` int(11) NOT NULL AUTO_INCREMENT COMMENT '主键',
`available` char(1) DEFAULT '0' COMMENT '是否可用0可用 1不可用',
`role` varchar(20) DEFAULT NULL COMMENT '角色标识程序中判断使用,如"admin"',
`description` varchar(100) DEFAULT NULL COMMENT '角色描述,UI界面显示使用',
PRIMARY KEY (`id`),
UNIQUE KEY `role` (`role`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
sys_user_role.sql(用户-角色表)
1
2
3
4
5
6
7
DROP TABLE IF EXISTS `sys_user_role`;
CREATE TABLE `sys_user_role` (
`uid` int(11) DEFAULT NULL COMMENT '用户id',
`role_id` int(11) DEFAULT NULL COMMENT '角色id',
KEY `uid` (`uid`) USING BTREE,
KEY `role_id` (`role_id`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
sys_permission.sql(权限表)
1
2
3
4
5
6
7
8
9
10
11
12
DROP TABLE IF EXISTS `sys_permission`;
CREATE TABLE `sys_permission` (
`id` int(11) NOT NULL AUTO_INCREMENT COMMENT '主键',
`parent_id` int(11) DEFAULT NULL COMMENT '父编号,本权限可能是该父编号权限的子权限',
`parent_ids` varchar(20) DEFAULT NULL COMMENT '父编号列表',
`permission` varchar(100) DEFAULT NULL COMMENT '权限字符串,menu例子:role:*,button例子:role:create,role:update,role:delete,role:view',
`resource_type` varchar(20) DEFAULT NULL COMMENT '资源类型,[menu|button]',
`url` varchar(200) DEFAULT NULL COMMENT '资源路径 如:/userinfo/list',
`name` varchar(50) DEFAULT NULL COMMENT '权限名称',
`available` char(1) DEFAULT '0' COMMENT '是否可用0可用 1不可用',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
sys_role_permission.sql(角色-权限表)
1
2
3
4
5
6
7
DROP TABLE IF EXISTS `sys_role_permission`;
CREATE TABLE `sys_role_permission` (
`role_id` int(11) DEFAULT NULL COMMENT '角色id',
`permission_id` int(11) DEFAULT NULL COMMENT '权限id',
KEY `role_id` (`role_id`) USING BTREE,
KEY `permission_id` (`permission_id`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
初始化数据导入
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#插入用户信息表
INSERT INTO user_info(uid,username,`password`,`name`,id_card_num) VALUES (null,'admin','123456','唐僧','133333333333333333');
INSERT INTO user_info(uid,username,`password`,`name`,id_card_num) VALUES (null,'test','123456','孙悟空','155555555555555555');
#插入用户角色表
INSERT INTO `sys_role` (`id`,`available`,`description`,`role`) VALUES (null,0,'管理员','admin');
INSERT INTO `sys_role` (`id`,`available`,`description`,`role`) VALUES (null,0,'VIP会员','vip');
INSERT INTO `sys_role` (`id`,`available`,`description`,`role`) VALUES (null,1,'测试','test');
#插入用户_角色关联表
INSERT INTO `sys_user_role` (`role_id`,`uid`) VALUES (1,1);
INSERT INTO `sys_user_role` (`role_id`,`uid`) VALUES (2,2);
#插入权限表
INSERT INTO `sys_permission` (`id`,`available`,`name`,`parent_id`,`parent_ids`,`permission`,`resource_type`,`url`) VALUES (null,0,'用户管理',0,'0/','userInfo:view','menu','userInfo/view');
INSERT INTO `sys_permission` (`id`,`available`,`name`,`parent_id`,`parent_ids`,`permission`,`resource_type`,`url`) VALUES (null,0,'用户添加',1,'0/1','userInfo:add','button','userInfo/add');
INSERT INTO `sys_permission` (`id`,`available`,`name`,`parent_id`,`parent_ids`,`permission`,`resource_type`,`url`) VALUES (null,0,'用户删除',1,'0/1','userInfo:del','button','userInfo/del');
#插入角色_权限表
INSERT INTO `sys_role_permission` (`permission_id`,`role_id`) 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);

创建实体类

用户信息
1
2
3
4
5
6
7
8
9
10
public class User {
private Integer uid;
private String username;
private String password;
private String name;
private String id_card_num;
private String state;
private Set<Role> roles = new HashSet<>();
//省略geter setter方法
}
角色信息
1
2
3
4
5
6
7
8
9
10
public class Role {
private Integer id;
private String role;
private String description;
private String available;
private Set<User> users = new HashSet<>();
private Set<Permission> permissions = new HashSet<>();
//省略geter setter方法

}
权限信息
1
2
3
4
5
6
7
8
9
10
11
12
13
public class Permission {
private Integer id;
private Integer parent_id;
private String parent_ids;
private String permission;
private String resource_type;
private String url;
private String name;
private String available;
private Set<Role> roles = new HashSet<>();
//省略geter setter方法

}

编写mapper

UserMapper
1
2
3
4
5
6
@Mapper
public interface UserMapper {
User findByUserName(String userName);
int insert(User user);
int del(@Param("username") String username);
}
UserMapper.xml
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
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd" >
<mapper namespace="com.demo.mapper.UserMapper">

<!-- 查询用户信息 -->
<select id="findByUserName" resultType="com.demo.entity.User">
SELECT * FROM user_info WHERE username = #{userName}
</select>

<!-- 添加用户 -->
<!-- 创建用户 -->
<insert id="insert" parameterType="com.demo.entity.User">
<selectKey resultType="java.lang.Integer" keyProperty="uid" order="AFTER">
SELECT
LAST_INSERT_ID()
</selectKey>
insert into user_info
<trim prefix="(" suffix=")" suffixOverrides=",">
<if test="uid != null">
uid,
</if>
<if test="username != null and username != ''">
username,
</if>
<if test="password != null and password != ''">
password,
</if>
<if test="name != null and name != ''">
`name`,
</if>
<if test="id_card_num != null and id_card_num != ''">
id_card_num,
</if>
<if test="state != null and state != ''">
state,
</if>
</trim>
<trim prefix="values (" suffix=")" suffixOverrides=",">
<if test="uid != null">
#{uid},
</if>
<if test="username != null and username != ''">
#{username},
</if>
<if test="password != null and password != ''">
#{password},
</if>
<if test="name != null and name != ''">
#{name},
</if>
<if test="id_card_num != null and id_card_num != ''">
#{id_card_num},
</if>
<if test="state != null and state != ''">
#{state},
</if>
</trim>
</insert>

<!-- 删除用户 -->
<delete id="del">
DELETE FROM user_info WHERE username = #{username}
</delete>

</mapper>

RoleMapper
1
2
3
4
@Mapper
public interface RoleMapper {
Set<Role> findRolesByUserId(@Param("uid") Integer uid);
}
RoleMapper.xml
1
2
3
4
5
6
7
8
9
10
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd" >
<mapper namespace="com.demo.mapper.RoleMapper">

<!-- 查询用户信息 -->
<select id="findRolesByUserId" resultType="com.demo.entity.Role">
SELECT r.* from sys_role r LEFT JOIN sys_user_role ur on r.id = ur.role_id where ur.uid = #{uid}
</select>

</mapper>
PermissionMapper
1
2
3
4
@Mapper
public interface PermissionMapper {
Set<Permission> findPermissionsByRoleId(@Param("roles") Set<Role> roles);
}
PermissionMapper.xml
1
2
3
4
5
6
7
8
9
10
11
12
13
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd" >
<mapper namespace="com.demo.mapper.PermissionMapper">

<!-- 查询用户权限信息 -->
<select id="findPermissionsByRoleId" resultType="com.demo.entity.Permission">
SELECT p.* from sys_permission p LEFT JOIN sys_role_permission rp on p.id = rp.permission_id WHERE rp.role_id IN
<foreach collection="roles" index="index" item="item" open="(" close=")" separator=",">
#{item.id}
</foreach>
</select>

</mapper>

Shiro 相关配置类

ShiroConfig

创建ShiroConfig.java配置类

我们需要定义一系列关于URL的规则和访问权限。

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
@Configuration
public class ShiroConfig {


/**
* ShiroFilterFactoryBean 处理拦截资源文件问题。
* 注意:初始化ShiroFilterFactoryBean的时候需要注入:SecurityManager
* Web应用中,Shiro可控制的Web请求必须经过Shiro主过滤器的拦截
*
* @param securityManager
* @return
*/
@Bean(name = "shirFilter")
public ShiroFilterFactoryBean shiroFilter(@Qualifier("securityManager") SecurityManager securityManager) {

ShiroFilterFactoryBean shiroFilterFactoryBean = new ShiroFilterFactoryBean();

//必须设置 SecurityManager,Shiro的核心安全接口
shiroFilterFactoryBean.setSecurityManager(securityManager);
//这里的/login是后台的接口名,非页面,如果不设置默认会自动寻找Web工程根目录下的"/login.jsp"页面
shiroFilterFactoryBean.setLoginUrl("/login");
//这里的/index是后台的接口名,非页面,登录成功后要跳转的链接
shiroFilterFactoryBean.setSuccessUrl("/index");
//未授权界面,该配置无效,并不会进行页面跳转
shiroFilterFactoryBean.setUnauthorizedUrl("/unauthorized");

//自定义拦截器限制并发人数,参考博客:
//LinkedHashMap<String, Filter> filtersMap = new LinkedHashMap<>();
//限制同一帐号同时在线的个数
//filtersMap.put("kickout", kickoutSessionControlFilter());
//shiroFilterFactoryBean.setFilters(filtersMap);

// 配置访问权限 必须是LinkedHashMap,因为它必须保证有序
// 过滤链定义,从上向下顺序执行,一般将 /**放在最为下边 一定要注意顺序,否则就不好使了
LinkedHashMap<String, String> filterChainDefinitionMap = new LinkedHashMap<>();
//配置不登录可以访问的资源,anon 表示资源都可以匿名访问
filterChainDefinitionMap.put("/login", "anon");
filterChainDefinitionMap.put("/", "anon");
filterChainDefinitionMap.put("/css/**", "anon");
filterChainDefinitionMap.put("/js/**", "anon");
filterChainDefinitionMap.put("/img/**", "anon");
filterChainDefinitionMap.put("/druid/**", "anon");
//logout是shiro提供的过滤器
filterChainDefinitionMap.put("/logout", "logout");
//此时访问/userInfo/del需要del权限,在自定义Realm中为用户授权。
//filterChainDefinitionMap.put("/userInfo/del", "perms[\"userInfo:del\"]");

//其他资源都需要认证 authc 表示需要认证才能进行访问
filterChainDefinitionMap.put("/**", "authc");

shiroFilterFactoryBean.setFilterChainDefinitionMap(filterChainDefinitionMap);

return shiroFilterFactoryBean;
}

/**
* 配置核心安全事务管理器
*
* @param shiroRealm
* @return
*/
@Bean(name = "securityManager")
public SecurityManager securityManager(@Qualifier("shiroRealm") ShiroRealm shiroRealm) {
DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager();
//设置自定义realm.
securityManager.setRealm(shiroRealm);
//配置记住我 参考博客:
//securityManager.setRememberMeManager(rememberMeManager());

//配置 redis缓存管理器 参考博客:
//securityManager.setCacheManager(getEhCacheManager());

//配置自定义session管理,使用redis 参考博客:
//securityManager.setSessionManager(sessionManager());

return securityManager;
}

/**
* 配置Shiro生命周期处理器
*
* @return
*/
@Bean(name = "lifecycleBeanPostProcessor")
public LifecycleBeanPostProcessor lifecycleBeanPostProcessor() {
return new LifecycleBeanPostProcessor();
}

/**
* 身份认证realm; (这个需要自己写,账号密码校验;权限等)
*
* @return
*/
@Bean
public ShiroRealm shiroRealm() {
ShiroRealm shiroRealm = new ShiroRealm();
return shiroRealm;
}

/**
* 必须(thymeleaf页面使用shiro标签控制按钮是否显示)
* 未引入thymeleaf包,Caused by: java.lang.ClassNotFoundException: org.thymeleaf.dialect.AbstractProcessorDialect
*
* @return
*/
@Bean
public ShiroDialect shiroDialect() {
return new ShiroDialect();
}
}
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.authz.PermissionsAuthorizationFilter
port org.apache.shiro.web.filter.authz.PortFilter
rest org.apache.shiro.web.filter.authz.HttpMethodPermissionFilter
roles org.apache.shiro.web.filter.authz.RolesAuthorizationFilter
ssl org.apache.shiro.web.filter.authz.SslFilter
user org.apache.shiro.web.filter.authc.UserFilter

anon:所有url都都可以匿名访问;
authc: 需要认证才能进行访问;
user:配置记住我或认证通过可以访问;

这几个是我们会用到的,在这里说明下,其它的请自行查询文档进行学习。

ShiroRealm

在认证、授权内部实现机制中都有提到,最终处理都将交给Real进行处理。因为在Shiro中,最终是通过Realm来获取应用程序中的用户、角色及权限信息的。通常情况下,在Realm中会直接从我们的数据源中获取Shiro需要的验证信息。可以说,Realm是专用于安全框架的DAO。

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
public class ShiroRealm extends AuthorizingRealm {

@Autowired
private UserMapper userMapper;

@Autowired
private RoleMapper roleMapper;

@Autowired
private PermissionMapper permissionMapper;

/**
* 验证用户身份
* @param authenticationToken
* @return
* @throws AuthenticationException
*/
@Override
protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authenticationToken) throws AuthenticationException {

//获取用户名密码 第一种方式
//String username = (String) authenticationToken.getPrincipal();
//String password = new String((char[]) authenticationToken.getCredentials());

//获取用户名 密码 第二种方式
UsernamePasswordToken usernamePasswordToken = (UsernamePasswordToken) authenticationToken;
String username = usernamePasswordToken.getUsername();
String password = new String(usernamePasswordToken.getPassword());

//从数据库查询用户信息
User user = this.userMapper.findByUserName(username);

//可以在这里直接对用户名校验,或者调用 CredentialsMatcher 校验
if (user == null) {
throw new UnknownAccountException("用户名或密码错误!");
}
if (!password.equals(user.getPassword())) {
throw new IncorrectCredentialsException("用户名或密码错误!");
}
if ("1".equals(user.getState())) {
throw new LockedAccountException("账号已被锁定,请联系管理员!");
}

//调用 CredentialsMatcher 校验 还需要创建一个类 继承CredentialsMatcher 如果在上面校验了,这个就不需要了
//配置自定义权限登录器 参考博客:

SimpleAuthenticationInfo info = new SimpleAuthenticationInfo(user,user.getPassword(), getName());
return info;
}

/**
* 授权用户权限
* 授权的方法是在碰到<shiro:hasPermission name=''></shiro:hasPermission>标签的时候调用的
* 它会去检测shiro框架中的权限(这里的permissions)是否包含有该标签的name值,如果有,里面的内容显示
* 如果没有,里面的内容不予显示(这就完成了对于权限的认证.)
*
* shiro的权限授权是通过继承AuthorizingRealm抽象类,重载doGetAuthorizationInfo();
* 当访问到页面的时候,链接配置了相应的权限或者shiro标签才会执行此方法否则不会执行
* 所以如果只是简单的身份认证没有权限的控制的话,那么这个方法可以不进行实现,直接返回null即可。
*
* 在这个方法中主要是使用类:SimpleAuthorizationInfo 进行角色的添加和权限的添加。
* authorizationInfo.addRole(role.getRole()); authorizationInfo.addStringPermission(p.getPermission());
*
* 当然也可以添加set集合:roles是从数据库查询的当前用户的角色,stringPermissions是从数据库查询的当前用户对应的权限
* authorizationInfo.setRoles(roles); authorizationInfo.setStringPermissions(stringPermissions);
*
* 就是说如果在shiro配置文件中添加了filterChainDefinitionMap.put("/add", "perms[权限添加]");
* 就说明访问/add这个链接必须要有“权限添加”这个权限才可以访问
*
* 如果在shiro配置文件中添加了filterChainDefinitionMap.put("/add", "roles[100002],perms[权限添加]");
* 就说明访问/add这个链接必须要有 "权限添加" 这个权限和具有 "100002" 这个角色才可以访问
* @param principalCollection
* @return
*/
@Override
protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principalCollection) {

//获取用户
User user = (User) SecurityUtils.getSubject().getPrincipal();

//获取用户角色
Set<Role> roles =this.roleMapper.findRolesByUserId(user.getUid());
//添加角色
SimpleAuthorizationInfo authorizationInfo = new SimpleAuthorizationInfo();
for (Role role : roles) {
authorizationInfo.addRole(role.getRole());
}

//获取用户权限
Set<Permission> permissions = this.permissionMapper.findPermissionsByRoleId(roles);
//添加权限
for (Permission permission:permissions) {
authorizationInfo.addStringPermission(permission.getPermission());
}

return authorizationInfo;
}
}

编写Service

UserService
1
2
3
4
5
6
7
8
public interface UserService {
User findByUserName(String userName);

int insert(User user);

int del(@Param("username") String username);
}

UserServiceImpl
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@Service
public class UserServiceImpl implements UserService {

private UserMapper userMapper;

@Override
public User findByUserName(String userName) {
return userMapper.findByUserName(userName);
}

@Override
public int insert(User user) {
return userMapper.insert(user);
}

@Override
public int del(String username) {
return userMapper.del(username);
}
}

RoleService
1
2
3
4
5
public interface RoleService {

Set<Role> findRolesByUserId(Integer uid);
}

RoleServiceIml
1
2
3
4
5
6
7
8
9
10
11
12
@Service
public class RoleServiceImpl implements RoleService {

@Autowired
private RoleMapper roleMapper;

@Override
public Set<Role> findRolesByUserId(Integer uid) {
return roleMapper.findRolesByUserId(uid);
}
}

PermissionService
1
2
3
4
5
public interface PermissionService {

Set<Permission> findPermissionsByRoleId(Set<Role> roles);
}

PermissionServiceImpl
1
2
3
4
5
6
7
8
9
10
11
@Service
public class PermissionServiceImpl implements PermissionService {

@Autowired
private PermissionMapper permissionMapper;

@Override
public Set<Permission> findPermissionsByRoleId(Set<Role> roles) {
return permissionMapper.findPermissionsByRoleId(roles);
}
}

编写Controller

LoginController
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
@Controller
public class LoginController {

/**
* 访问项目根路径
* @return
*/
@RequestMapping(value = "/",method = RequestMethod.GET)
public String root(Model model) {
Subject subject = SecurityUtils.getSubject();
User user=(User) subject.getPrincipal();
if (user == null){
return "redirect:/login";
}else{
return "redirect:/index";
}

}


/**
* 跳转到login页面
* @return
*/
@RequestMapping(value = "/login",method = RequestMethod.GET)
public String login(Model model) {
Subject subject = SecurityUtils.getSubject();
User user=(User) subject.getPrincipal();
if (user == null){
return "login";
}else{
return "redirect:index";
}

}

/**
* 用户登录
* @param request
* @param username
* @param password
* @param model
* @param session
* @return
*/
@RequestMapping(value = "/login",method = RequestMethod.POST)
public String loginUser(HttpServletRequest request, String username, String password, Model model, HttpSession session) {

//对密码进行加密
//password=new SimpleHash("md5", password, ByteSource.Util.bytes(username.toLowerCase() + "shiro"),2).toHex();
//如果有点击 记住我
//UsernamePasswordToken usernamePasswordToken=new UsernamePasswordToken(username,password,remeberMe);
UsernamePasswordToken usernamePasswordToken = new UsernamePasswordToken(username,password);
Subject subject = SecurityUtils.getSubject();
try {
//登录操作
subject.login(usernamePasswordToken);
User user=(User) subject.getPrincipal();
//更新用户登录时间,也可以在ShiroRealm里面做
session.setAttribute("user", user);
model.addAttribute("user",user);
return "index";
} catch(Exception e) {
//登录失败从request中获取shiro处理的异常信息 shiroLoginFailure:就是shiro异常类的全类名
String exception = (String) request.getAttribute("shiroLoginFailure");
model.addAttribute("msg",e.getMessage());
//返回登录页面
return "login";
}
}

@RequestMapping("/index")
public String index(HttpSession session, Model model) {
Subject subject = SecurityUtils.getSubject();
User user=(User) subject.getPrincipal();
if (user == null){
return "login";
}else{
model.addAttribute("user",user);
return "index";
}
}

/**
* 登出 这个方法没用到,用的是shiro默认的logout
* @param session
* @param model
* @return
*/
@RequestMapping("/logout")
public String logout(HttpSession session, Model model) {
Subject subject = SecurityUtils.getSubject();
subject.logout();
model.addAttribute("msg","安全退出!");
return "login";
}
}
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
50
@RestController
@RequestMapping("userInfo")
public class UserController {

@Autowired
private UserService userService;

/**
* 创建固定写死的用户
* @param model
* @return
*/
@RequestMapping(value = "/add",method = RequestMethod.GET)
@ResponseBody
public String login(Model model) {

User user = new User();
user.setName("王赛超");
user.setId_card_num("177777777777777777");
user.setUsername("baiyp");

userService.insert(user);

return "创建用户成功";

}

/**
* 删除固定写死的用户
* @param model
* @return
*/
@RequestMapping(value = "/del",method = RequestMethod.GET)
@ResponseBody
public String del(Model model) {

userService.del("baiyp");

return "删除用户名为baiyp用户成功";

}

@RequestMapping(value = "/view",method = RequestMethod.GET)
@ResponseBody
public String view(Model model) {

return "这是用户列表页";

}
}
UnifiedErrorController
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
/**
* 统一错误处理controller
*/
@Controller
public class UnifiedErrorController {

/**
* 跳转到无权限页面
*
* @param session
* @param model
* @return
*/
@RequestMapping("/unauthorized")
public String unauthorized(HttpSession session, Model model) {
return "unauthorized";
}


/**
* 跳转到无权限页面
*
* @param session
* @param model
* @return
*/
@RequestMapping("/404")
public String error404(HttpSession session, Model model) {
return "404";
}

/**
* 跳转到无权限页面
*
* @param session
* @param model
* @return
*/
@RequestMapping("/500")
public String error500(HttpSession session, Model model) {
return "500";
}
}

新建页面

login.html
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org"
xmlns:sec="http://www.thymeleaf.org/thymeleaf-extras-springsecurity3"
xmlns:shiro="http://www.pollix.at/thymeleaf/shiro">
<head>
<meta charset="UTF-8"/>
<title>Insert title here</title>
</head>
<body>
<h1>欢迎登录</h1>
<h1 th:if="${msg != null }" th:text="${msg}" style="color: red"></h1>
<form action="/login" method="post">
用户名:<input type="text" name="username"/><br/>
&nbsp;&nbsp;码:<input type="password" name="password"/><br/>
<input type="submit" value="提交"/>
</form>
</body>
</html>

index.html
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
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org"
xmlns:sec="http://www.thymeleaf.org/thymeleaf-extras-springsecurity3"
xmlns:shiro="http://www.pollix.at/thymeleaf/shiro">
<head>
<meta charset="UTF-8"/>
<title>Insert title here</title>
</head>
<body>
<h1 th:text="'欢迎' + ${user.username } + '光临!请选择你的操作'"></h1><br/>
<ul>
<h1 th:if="${msg != null }" th:text="${msg}" style="color: red"></h1>

<shiro:hasPermission name="userInfo:add"><a href="/userInfo/add">点击添加固定用户信息(后台写死,方便测试)</a></shiro:hasPermission>
<br/>
<shiro:hasPermission name="userInfo:del"><a href="/userInfo/del">点击删除固定用户信息(后台写死,方便测试)</a></shiro:hasPermission>
<br/>
<shiro:hasPermission name="userInfo:view"><a href="/userInfo/view">显示此内容表示拥有查看用户列表的权限</a></shiro:hasPermission>
<br/>


<!-- 用户没有身份验证时显示相应信息,即游客访问信息 -->
<shiro:guest>游客显示的信息</shiro:guest>
<br/>
<!-- 用户已经身份验证/记住我登录后显示相应的信息 -->
<shiro:user>用户已经登录过了</shiro:user>
<br/>
<!-- 用户已经身份验证通过,即Subject.login登录成功,不是记住我登录的 -->
<shiro:authenticated>不是记住我登录</shiro:authenticated>
<br/>
<!-- 显示用户身份信息,通常为登录帐号信息,默认调用Subject.getPrincipal()获取,即Primary Principal -->
<shiro:principal></shiro:principal>
<br/>
<!--用户已经身份验证通过,即没有调用Subject.login进行登录,包括记住我自动登录的也属于未进行身份验证,与guest标签的区别是,该标签包含已记住用户 -->
<shiro:notAuthenticated>已记住用户</shiro:notAuthenticated>
<br/>
<!-- 相当于Subject.getPrincipals().oneByType(String.class) -->
<shiro:principal type="java.lang.String"/>
<br/>
<!-- 相当于((User)Subject.getPrincipals()).getUsername() -->
<shiro:principal property="username"/>
<br/>
<!-- 如果当前Subject有角色将显示body体内容 name="角色名" -->
<shiro:hasRole name="admin">这是admin角色</shiro:hasRole>
<br/>
<!-- 如果当前Subject有任意一个角色(或的关系)将显示body体内容。 name="角色名1,角色名2..." -->
<shiro:hasAnyRoles name="admin,vip">用户拥有admin角色 或者 vip角色</shiro:hasAnyRoles>
<br/>
<!-- 如果当前Subject没有角色将显示body体内容 -->
<shiro:lacksRole name="admin">如果不是admin角色,显示内容</shiro:lacksRole>
<br/>
<!-- 如果当前Subject有权限将显示body体内容 name="权限名" -->
<shiro:hasPermission name="userInfo:add">用户拥有添加权限</shiro:hasPermission>
<br/>
<!-- 用户同时拥有以下两种权限,显示内容 -->
<shiro:hasAllPermissions name="userInfo:add,userInfo:view">用户同时拥有列表权限和添加权限</shiro:hasAllPermissions>
<br/>
<!-- 用户拥有以下权限任意一种 -->
<shiro:hasAnyPermissions name="userInfo:view,userInfo:del">用户拥有列表权限或者删除权限</shiro:hasAnyPermissions>
<br/>
<!-- 如果当前Subject没有权限将显示body体内容 name="权限名" -->
<shiro:lacksPermission name="userInfo:add">如果用户没有添加权限,显示的内容</shiro:lacksPermission>
<br/>
</ul>
<a href="/logout">点我注销</a>
</body>
</html>
unauthorized.html
1
2
3
4
5
6
7
8
9
10
11
12
13
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org"
xmlns:sec="http://www.thymeleaf.org/thymeleaf-extras-springsecurity3"
xmlns:shiro="http://www.pollix.at/thymeleaf/shiro">
<head>
<meta charset="UTF-8"/>
<title>Insert title here</title>
</head>
<body>
<h1>对不起,您没有权限</h1>
</body>
</html>

500.html
1
2
3
4
5
6
7
8
9
10
11
12
13
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org"
xmlns:sec="http://www.thymeleaf.org/thymeleaf-extras-springsecurity3"
xmlns:shiro="http://www.pollix.at/thymeleaf/shiro">
<head>
<meta charset="UTF-8"/>
<title>Insert title here</title>
</head>
<body>
<h1>对不起,服务器报错了</h1>
</body>
</html>

404.html
1
2
3
4
5
6
7
8
9
10
11
12
13
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org"
xmlns:sec="http://www.thymeleaf.org/thymeleaf-extras-springsecurity3"
xmlns:shiro="http://www.pollix.at/thymeleaf/shiro">
<head>
<meta charset="UTF-8"/>
<title>Insert title here</title>
</head>
<body>
<h1>对不起,你的页面没有找到</h1>
</body>
</html>

进行身份验证测试

第一步: 访问http://localhost:8080/userInfo/add 发现自动跳转到登录页

第二步:使用admin登录

第三步:注销之后使用test登录

不同的用户登录,显示不同的功能,点击之后也可以调用后台服务,证明身份验证成功。

权限功能校验

经过上面的过程,已经可以对用户的身份进行校验,但是这个时候,但是权限控制好像没有什么作用,因为我们使用admin用户登录之后,在浏览器上访问地址 /userInfo/del发现也是可以使用的,其实我们还少了以下步骤,也就是开启注解支持

开启Shiro的注解

在ShiroConfig中配置以下bean

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
/**
* 开启Shiro的注解(如@RequiresRoles,@RequiresPermissions)
*
* @return
*/
@Bean
public static DefaultAdvisorAutoProxyCreator getDefaultAdvisorAutoProxyCreator() {
DefaultAdvisorAutoProxyCreator defaultAdvisorAutoProxyCreator = new DefaultAdvisorAutoProxyCreator();
defaultAdvisorAutoProxyCreator.setProxyTargetClass(true);
return defaultAdvisorAutoProxyCreator;
}

/**
* 开启shiro 注解模式
* 可以在controller中的方法前加上注解
* 如 @RequiresPermissions("userInfo:add")
*
* @param securityManager
* @return
*/
@Bean
public AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor(@Qualifier("securityManager") SecurityManager securityManager) {
AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor = new AuthorizationAttributeSourceAdvisor();
authorizationAttributeSourceAdvisor.setSecurityManager(securityManager);
return authorizationAttributeSourceAdvisor;
}

在controller增加权限

1
2
3
4
5
6
7
8
@RequiresPermissions("userInfo:del")
@RequestMapping(value = "/del",method = RequestMethod.GET)
@ResponseBody
public String del(Model model) {
userService.del("baiyp");
return "删除用户名为baiyp用户成功";

}

添加@RequiresPermissions("userInfo:del")然后重启项目,再次使用amdin登录之后,在浏览器上调用http://localhost:9090/userInfo/del就会跳转到以下错误页。证明权限校验成功。

后台会报以下异常:调用未授权的方法

到此,shiro入门完了,我相信很多人对shiro 已经可以说了解怎么用了,其实还有很多问题:

  1. 首先是错误页显示,没有权限理论应该跳转到我们配置的无权限的页面,但是并没有
  2. 我们不断的访问http://localhost:8080/userInfo/view 发现每次都会去数据库查询权限,但是实际中我们的权限信息是不怎么会改变的,所以我们希望是第一次访问,然后进行缓存处理等等,这些会在后面的文章中。

解决 Whitelabel Error Page

页面显示为默认springboot展示的页面Whitelabel Error Page,具体页面如下,并没有跳转到我们之前配置的页面 。

配置异常处理器

新增SimpleMappingExceptionResolver

​ 也就是说shiroFilterFactoryBean.setUnauthorizedUrl(“/unauthorized”);无效!但是有时候我们需要向客户展示友好界面,所以,还需要如下配置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/**
* 解决: 无权限页面不跳转 shiroFilterFactoryBean.setUnauthorizedUrl("/unauthorized") 无效
* shiro的源代码ShiroFilterFactoryBean.Java定义的filter必须满足filter instanceof AuthorizationFilter,
* 只有perms,roles,ssl,rest,port才是属于AuthorizationFilter,而anon,authcBasic,auchc,user是AuthenticationFilter,
* 所以unauthorizedUrl设置后页面不跳转 Shiro注解模式下,登录失败与没有权限都是通过抛出异常。
* 并且默认并没有去处理或者捕获这些异常。在SpringMVC下需要配置捕获相应异常来通知用户信息
*
* @return
*/
@Bean
public SimpleMappingExceptionResolver simpleMappingExceptionResolver() {
SimpleMappingExceptionResolver simpleMappingExceptionResolver = new SimpleMappingExceptionResolver();
Properties properties = new Properties();
//这里的 /unauthorized 是页面,不是访问的路径
properties.setProperty("org.apache.shiro.authz.UnauthorizedException", "/unauthorized");
properties.setProperty("org.apache.shiro.authz.UnauthenticatedException", "/unauthorized");
simpleMappingExceptionResolver.setExceptionMappings(properties);
return simpleMappingExceptionResolver;
}

测试

然后重新启动程序,发现访问无权限的后台服务,就会跳转到我们配置的页面。

但是这个时候还有一个问题:访问不存在的后台服务,如http://localhost:8080/userInfo/xxxxx 这样后面随便乱打的,这种情况,依然还是返回了Whitelabel Error Page页面。

我们不想看到这种效果,所以还需要在ShiroConfig配置类中,添加以下bean

1
2
3
4
5
6
7
8
9
10
11
12
13
/**
* 解决spring-boot2.0 Whitelabel Error Page
*
* @return
*/
@Bean
public WebServerFactoryCustomizer<ConfigurableWebServerFactory> webServerFactoryCustomizer() {
return factory -> {
ErrorPage error401Page = new ErrorPage(HttpStatus.UNAUTHORIZED, "/unauthorized");
ErrorPage error404Page = new ErrorPage(HttpStatus.NOT_FOUND, "/404");
ErrorPage error500Page = new ErrorPage(HttpStatus.INTERNAL_SERVER_ERROR, "/500");
factory.addErrorPages(error401Page, error404Page, error500Page);
};

再次访问http://localhost:8080/userInfo/xxxxx 因为服务根本不存在,就会跳转到404页面。

如果访问http://localhost:8080/userInfo/del 就会跳转到unauthorized.html 因为没有权限。

配置记住我

记住我功能在各各网站是比较常见的,实现起来也都差不多,主要就是利用cookie来实现,而shiro对记住我功能的实现也是比较简单的,只需要几步即可。

基本流程

​ Shiro提供了记住我(RememberMe)的功能,比如访问一些网站时,关闭了浏览器下次再打开时还是能记住你是谁,下次访问时无需再登录即可访问,基本流程如下:

  1. 首先在登录页面选中RememberMe然后登录成功;如果是浏览器登录,一般会把RememberMe的Cookie写到客户端并保存下来;
  2. 关闭浏览器再重新打开;会发现浏览器还是记住你的;
  3. 访问一般的网页服务器端还是知道你是谁,且能正常访问;

ShiroConfig相关配置

记住我cookie
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
/**
* cookie对象;会话Cookie模板 ,默认为: JSESSIONID 问题: 与SERVLET容器名冲突,重新定义为sid或rememberMe,自定义
*
* @return
*/
@Bean
public SimpleCookie rememberMeCookie() {
//这个参数是cookie的名称,对应前端的checkbox的name = rememberMe
SimpleCookie simpleCookie = new SimpleCookie("rememberMe");
//setcookie的httponly属性如果设为true的话,会增加对xss防护的安全系数。它有以下特点:

//setcookie()的第七个参数
//设为true后,只能通过http访问,javascript无法访问
//防止xss读取cookie
simpleCookie.setHttpOnly(true);
simpleCookie.setPath("/");
//<!-- 记住我cookie生效时间30天 ,单位秒;-->
simpleCookie.setMaxAge(2592000);
return simpleCookie;
}
记住我管理器
1
2
3
4
5
6
7
8
9
10
11
12
13
/**
* cookie管理对象;记住我功能,rememberMe管理器
*
* @return
*/
@Bean
public CookieRememberMeManager rememberMeManager() {
CookieRememberMeManager cookieRememberMeManager = new CookieRememberMeManager();
cookieRememberMeManager.setCookie(rememberMeCookie());
//rememberMe cookie加密的密钥 建议每个项目都不一样 默认AES算法 密钥长度(128 256 512 位)
cookieRememberMeManager.setCipherKey(Base64.decode("6ZmI6I2j5Y+R5aSn5ZOlAA=="));
return cookieRememberMeManager;
}
记住我Filter
1
2
3
4
5
6
7
8
9
10
11
12
/**
* FormAuthenticationFilter 过滤器 过滤记住我
*
* @return
*/
@Bean
public FormAuthenticationFilter formAuthenticationFilter() {
FormAuthenticationFilter formAuthenticationFilter = new FormAuthenticationFilter();
//对应前端的checkbox的name = rememberMe
formAuthenticationFilter.setRememberMeParam("rememberMe");
return formAuthenticationFilter;
}

修改相关代码

修改安全事务管理器

在SecurityManager中打开记住我,之前是注释掉的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/**
* 配置核心安全事务管理器
*
* @param shiroRealm
* @return
*/
@Bean(name = "securityManager")
public SecurityManager securityManager(@Qualifier("shiroRealm") ShiroRealm shiroRealm) {
DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager();
//设置自定义realm.
securityManager.setRealm(shiroRealm);
//配置记住我
securityManager.setRememberMeManager(rememberMeManager());
...

return securityManager;
}
修改拦截规则

修改shirFilter中拦截请求的规则,将/**从authc 改为user

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/**
* ShiroFilterFactoryBean 处理拦截资源文件问题。
* 注意:初始化ShiroFilterFactoryBean的时候需要注入:SecurityManager
* Web应用中,Shiro可控制的Web请求必须经过Shiro主过滤器的拦截
*
* @param securityManager
* @return
*/
@Bean(name = "shirFilter")
public ShiroFilterFactoryBean shiroFilter(@Qualifier("securityManager") SecurityManager securityManager) {
...

//其他资源都需要认证 authc 表示需要认证才能进行访问
// filterChainDefinitionMap.put("/**", "authc");
//其他资源都需要认证 authc 表示需要认证才能进行访问 user表示配置记住我或认证通过可以访问的地址
filterChainDefinitionMap.put("/**", "user");

...

return shiroFilterFactoryBean;
}
更改LoginController

接受前台记住我参数,然后传给usernamePasswordToken,将之前的那行注释掉。

1
2
3
4
5
6
7
8
9
10
11
@RequestMapping(value = "/login", method = RequestMethod.POST)
public String loginUser(HttpServletRequest request, String username, String password, boolean rememberMe, Model model, HttpSession session) {

//对密码进行加密
//password=new SimpleHash("md5", password, ByteSource.Util.bytes(username.toLowerCase() + "shiro"),2).toHex();
//如果有点击 记住我
UsernamePasswordToken usernamePasswordToken = new UsernamePasswordToken(username, password, rememberMe);
//注释掉
// UsernamePasswordToken usernamePasswordToken = new UsernamePasswordToken(username,password);
...
}
更改User实体类

User实体类必须实现序列化接口,否则报序列化异常。因为RememberMe会将用户信息加密然后以Cookie保存。

1
2
3
public class User implements Serializable {
...
}
更改Login.html

前台页面添加记住我复选框

1
2
3
4
5
6
7
8
9
10
...
<h1>欢迎登录</h1>
<h1 th:if="${msg != null }" th:text="${msg}" style="color: red"></h1>
<form action="/login" method="post">
用户名:<input type="text" name="username"/><br/>
&nbsp;&nbsp;码:<input type="password" name="password"/><br/>
<!--
<input type="checkbox" name="rememberMe" />记住我<br/>
<input type="submit" value="提交"/>
...

测试

上面的过程已经配置完成,现在进行测试,重启项目,使用admin登录,然后访问:http://localhost:8080/userInfo/view会跳转到登录页面,登录之后退出浏览器,再次点开访问刚才的页面,可以访问。

浏览器cookie查看

rememberMe 大概内容如下
1
blDl1IGoHN12pWm/dS8+PCwp81NwooEbdvkxMreWffEGcWtVeOUBusIPgjnUbByPhK9dxXuybhvUwjf5AVrYoACHHN3qim0A0Gw1jIPMYibzbcra+dYkBAQo3PKq+bPzDg99zSM60PcT4k7ZauMoGnZmTKrmgItZ2FytCFam9xUBgueIgSSocoHpG2yedJI9RqgCr9KNJVeSg9NKiSJ3cfqTjbjvHZ2yXzTym0735Pq9hZvJ0PicqqXtZphVzIXL4TfER7MRInKgD1GmLXdhGySKHrnvzpGrqUj4f4a1C6IjbTwRcE+Gf2nK9ydqcrHqAjlrbADn8uVInP9uqTDtHU0tiB73M6GQtwdXrMWXD9r75FhUCYZVA9ZxrMgxpISaVBygCWoLjQpR8SR/PRG40qP6lNqs/xjEVd+EUcfIIKdWTVQhCv4/HDCFxF2w5CFPY2KZqWmClLIPKjUVf+40Otur8/eLJZE9LSsb2GiKpZOnFy4/5rgVWLvPGWBcca/UaO/ZYJ/cANHP70/rmIsRblu42ZAXGZiIyI++S+hxDPgfTU44X2xKCKaFhQLkWV0akoXrmrwQV3aMcJrMusIL2lIk+Cyw0yhCxePPQQyuvHTRcm8LpFtVLm1nOqv+l2TRgIVjhKXutIRIFWNPP05q0+LSnJ+3xYSO9Kvo36PN+EqjU2vTy5oTbNaqkm070NXclKju0f37+c9J4VVz96I3dvSHqBgYevRDUGOdw5LuDWP7lh83l8ptfn1m5Mn2Hr9TmLrbscAN8c00sorGOaB1mcjlQyH7uYoAF/i03RfCD7uA1RMd+6NSXyeSFehmueVn58rv3BHPVS9UlZxoiGXoPkP3sQc0QKdccCfN0Iolt2ZnPmvZDpx3VA963zzqfMYYYc6T/3q0XCyga6lVdMqsi+llkTsydQWx3rtRSLfyTkHTpDxmZv5qfR+f8mDPRJ6qI0pKf8SuTr0=

其实是将用户信息加密后放到前台的(包含密码),在项目中user对象信息过于庞大,不能全部存入Cookie,Cookie对长度有一定的限制。

整合shiro-ehcache缓存

我们最后有两个问题,其中关于无权限页面的问题已经解决了,还有一个问题就是每次访问服务,都会去查询数据库,真实的情况是权限并不会经常出现变化,所以最好的办法就是做缓存处理,如果只是公司后台运营管理系统,可能只启动一个单节点就够了,这个时候我们就可以用到ehcache缓存。

前置工作

修改RoleService

增加delPermission,addPermission 方法

1
2
3
4
5
6
7
8
public interface RoleService {

Set<Role> findRolesByUserId(Integer uid);

void delPermission(int roleId, int permissionId);

void addPermission(int i, int i1);
}
RoleServiceImpl
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@Service
public class RoleServiceImpl implements RoleService {

@Autowired
private RoleMapper roleMapper;

@Override
public Set<Role> findRolesByUserId(Integer uid) {
return roleMapper.findRolesByUserId(uid);
}

@Override
public void delPermission(int roleId, int permissionId) {
roleMapper.delPermission(roleId,permissionId);
}

@Override
public void addPermission(int roleId, int permissionId) {
roleMapper.addPermission(roleId,permissionId);
}
}
修改RoleMapper
1
2
3
4
5
6
7
8
9
@Mapper
public interface RoleMapper {
Set<Role> findRolesByUserId(@Param("uid") Integer uid);

void delPermission(@Param("roleId") Integer roleId, @Param("permissionId") Integer permissionId);

void addPermission(@Param("roleId") Integer roleId, @Param("permissionId") Integer permissionId);
}

RoleMapper.xml
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd" >
<mapper namespace="com.demo.mapper.RoleMapper">

<!-- 查询用户信息 -->
<select id="findRolesByUserId" resultType="com.demo.entity.Role">
SELECT r.* from sys_role r LEFT JOIN sys_user_role ur on r.id = ur.role_id where ur.uid = #{uid}
</select>

<insert id="addPermission">
INSERT INTO sys_role_permission (role_id, permission_id)
VALUES
(#{roleId},#{permissionId})
</insert>

<delete id="delPermission">
DELETE FROM sys_role_permission WHERE role_id=#{roleId} and permission_id=#{permissionId}
</delete>

</mapper>

pom添加依赖

1
2
3
4
5
<dependency>
<groupId>org.apache.shiro</groupId>
<artifactId>shiro-ehcache</artifactId>
<version>1.5.2</version>
</dependency>

修改ShiroConfig

在ShiroConfig中添加缓存管理器

添加shiro缓存管理器
1
2
3
4
5
6
7
8
9
10
11
12
/**
* shiro缓存管理器;
* 需要添加到securityManager中
*
* @return
*/
@Bean
public EhCacheManager ehCacheManager() {
EhCacheManager cacheManager = new EhCacheManager();
cacheManager.setCacheManagerConfigFile("classpath:config/ehcache-shiro.xml");
return cacheManager;
}
添加Spring静态注入
1
2
3
4
5
6
7
8
9
10
11
12
13
/**
* 让某个实例的某个方法的返回值注入为Bean的实例
* Spring静态注入
*
* @return
*/
@Bean
public MethodInvokingFactoryBean getMethodInvokingFactoryBean() {
MethodInvokingFactoryBean factoryBean = new MethodInvokingFactoryBean();
factoryBean.setStaticMethod("org.apache.shiro.SecurityUtils.setSecurityManager");
factoryBean.setArguments(new Object[]{securityManager()});
return factoryBean;
}
修改安全事务管理器
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/**
* 配置核心安全事务管理器
*
* @return
*/
@Bean(name = "securityManager")
public SecurityManager securityManager() {
DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager();
//设置自定义realm.
securityManager.setRealm(shiroRealm());
//配置记住我
securityManager.setRememberMeManager(rememberMeManager());

//配置 ehcache缓存管理器
securityManager.setCacheManager(ehCacheManager());

//配置自定义session管理,使用redis
//securityManager.setSessionManager(sessionManager());

return securityManager;
}
重写shiroRealm方法

重写shiroRealm开启Ehcache缓存

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
/**
* 身份认证realm; (这个需要自己写,账号密码校验;权限等)
*
* @return
*/
@Bean
public ShiroRealm shiroRealm() {
ShiroRealm shiroRealm = new ShiroRealm();
shiroRealm.setCachingEnabled(true);
//启用身份验证缓存,即缓存AuthenticationInfo信息,默认false
shiroRealm.setAuthenticationCachingEnabled(true);
//缓存AuthenticationInfo信息的缓存名称 在ehcache-shiro.xml中有对应缓存的配置
shiroRealm.setAuthenticationCacheName("authenticationCache");
//启用授权缓存,即缓存AuthorizationInfo信息,默认false
shiroRealm.setAuthorizationCachingEnabled(true);
//缓存AuthorizationInfo信息的缓存名称 在ehcache-shiro.xml中有对应缓存的配置
shiroRealm.setAuthorizationCacheName("authorizationCache");

return shiroRealm;
}

配置ehcache配置文件

在config下添加ehcache-shiro.xml

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
<?xml version="1.0" encoding="UTF-8"?>
<ehcache name="es">

<!--
缓存对象存放路径
java.io.tmpdir:默认的临时文件存放路径。
user.home:用户的主目录。
user.dir:用户的当前工作目录,即当前程序所对应的工作路径。
其它通过命令行指定的系统属性,如“java –DdiskStore.path=D:\\abc ……”。
-->
<diskStore path="java.io.tmpdir"/>

<!--

name:缓存名称。
maxElementsOnDisk:硬盘最大缓存个数。0表示不限制
maxEntriesLocalHeap:指定允许在内存中存放元素的最大数量,0表示不限制。
maxBytesLocalDisk:指定当前缓存能够使用的硬盘的最大字节数,其值可以是数字加单位,单位可以是K、M或者G,不区分大小写,
如:30G。当在CacheManager级别指定了该属性后,Cache级别也可以用百分比来表示,
如:60%,表示最多使用CacheManager级别指定硬盘容量的60%。该属性也可以在运行期指定。当指定了该属性后会隐式的使当前Cache的overflowToDisk为true。
maxEntriesInCache:指定缓存中允许存放元素的最大数量。这个属性也可以在运行期动态修改。但是这个属性只对Terracotta分布式缓存有用。
maxBytesLocalHeap:指定当前缓存能够使用的堆内存的最大字节数,其值的设置规则跟maxBytesLocalDisk是一样的。
maxBytesLocalOffHeap:指定当前Cache允许使用的非堆内存的最大字节数。当指定了该属性后,会使当前Cache的overflowToOffHeap的值变为true,
如果我们需要关闭overflowToOffHeap,那么我们需要显示的指定overflowToOffHeap的值为false。
overflowToDisk:boolean类型,默认为false。当内存里面的缓存已经达到预设的上限时是否允许将按驱除策略驱除的元素保存在硬盘上,默认是LRU(最近最少使用)。
当指定为false的时候表示缓存信息不会保存到磁盘上,只会保存在内存中。
该属性现在已经废弃,推荐使用cache元素的子元素persistence来代替,如:<persistence strategy=”localTempSwap”/>。
diskSpoolBufferSizeMB:当往磁盘上写入缓存信息时缓冲区的大小,单位是MB,默认是30。
overflowToOffHeap:boolean类型,默认为false。表示是否允许Cache使用非堆内存进行存储,非堆内存是不受Java GC影响的。该属性只对企业版Ehcache有用。
copyOnRead:当指定该属性为true时,我们在从Cache中读数据时取到的是Cache中对应元素的一个copy副本,而不是对应的一个引用。默认为false。
copyOnWrite:当指定该属性为true时,我们在往Cache中写入数据时用的是原对象的一个copy副本,而不是对应的一个引用。默认为false。
timeToIdleSeconds:单位是秒,表示一个元素所允许闲置的最大时间,也就是说一个元素在不被请求的情况下允许在缓存中待的最大时间。默认是0,表示不限制。
timeToLiveSeconds:单位是秒,表示无论一个元素闲置与否,其允许在Cache中存在的最大时间。默认是0,表示不限制。
eternal:boolean类型,表示是否永恒,默认为false。如果设为true,将忽略timeToIdleSeconds和timeToLiveSeconds,Cache内的元素永远都不会过期,也就不会因为元素的过期而被清除了。
diskExpiryThreadIntervalSeconds :单位是秒,表示多久检查元素是否过期的线程多久运行一次,默认是120秒。
clearOnFlush:boolean类型。表示在调用Cache的flush方法时是否要清空MemoryStore。默认为true。
diskPersistent:是否缓存虚拟机重启期数据 Whether the disk store persists between restarts of the Virtual Machine. The default value is false.
maxElementsInMemory:缓存最大数目
memoryStoreEvictionPolicy:当达到maxElementsInMemory限制时,Ehcache将会根据指定的策略去清理内存。默认策略是LRU(最近最少使用)。你可以设置为FIFO(先进先出)或是LFU(较少使用)。
memoryStoreEvictionPolicy:
Ehcache的三种清空策略;
FIFO,first in first out,这个是大家最熟的,先进先出。
LFU, Less Frequently Used,就是上面例子中使用的策略,直白一点就是讲一直以来最少被使用的。如上面所讲,缓存的元素有一个hit属性,hit值最小的将会被清出缓存。
LRU,Least Recently Used,最近最少使用的,缓存的元素有一个时间戳,当缓存容量满了,而又需要腾出地方来缓存新的元素的时候,那么现有缓存元素中时间戳离当前时间最远的元素将被清出缓存。
-->
<defaultCache
maxElementsInMemory="10000"
eternal="false"
timeToIdleSeconds="0"
timeToLiveSeconds="0"
overflowToDisk="false"
diskPersistent="false"
diskExpiryThreadIntervalSeconds="120"
/>

<!-- 授权缓存 -->
<cache name="authorizationCache"
maxEntriesLocalHeap="2000"
eternal="false"
timeToIdleSeconds="0"
timeToLiveSeconds="0"
overflowToDisk="false"
statistics="true">
</cache>

<!-- 认证缓存 -->
<cache name="authenticationCache"
maxEntriesLocalHeap="2000"
eternal="false"
timeToIdleSeconds="0"
timeToLiveSeconds="0"
overflowToDisk="false"
statistics="true">
</cache>

</ehcache>

在ShiroRealm的doGetAuthorizationInfo方法中添加日志,或者直接看控制台sql打印也行。
启动项目,使用admin登录,访问http://localhost:8080/userInfo/view 第一次访问,查看控制台,有sql查询,再次访问将不再打印查询数据库的日志,证明缓存生效了。上面的缓存配置时间配置为永久,请根据需求自己更改值来进行测试。
关于在ShiroRealm中对 用户信息 角色信息 权限信息的查询,如果需要添加缓存 请自行处理。

缓存更新

如果用户的权限发生改变怎么办

​ 上面已经启用了缓存,第一次请求走数据库查询,后续请求将直接查询ehcache缓存,假如这个时候在权限控制台分配了某个权限给某个角色,那么拥有这个角色的所有用户在下次请求之前都需要从数据库查询最新的权限信息。下面开始进行在权限发生改变时,该如何做:

清理缓存

在ShiroRealm类中添加以下方法(清理缓存)

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
public class ShiroRealm extends AuthorizingRealm {

@Autowired
private UserMapper userMapper;

@Autowired
private RoleMapper roleMapper;

@Autowired
private PermissionMapper permissionMapper;

...

/**
* 重写方法,清除当前用户的的 授权缓存
* @param principals
*/
@Override
public void clearCachedAuthorizationInfo(PrincipalCollection principals) {
super.clearCachedAuthorizationInfo(principals);
}

/**
* 重写方法,清除当前用户的 认证缓存
* @param principals
*/
@Override
public void clearCachedAuthenticationInfo(PrincipalCollection principals) {
super.clearCachedAuthenticationInfo(principals);
}

@Override
public void clearCache(PrincipalCollection principals) {
super.clearCache(principals);
}

/**
* 自定义方法:清除所有 授权缓存
*/
public void clearAllCachedAuthorizationInfo() {
getAuthorizationCache().clear();
}

/**
* 自定义方法:清除所有 认证缓存
*/
public void clearAllCachedAuthenticationInfo() {
getAuthenticationCache().clear();
}

/**
* 自定义方法:清除所有的 认证缓存 和 授权缓存
*/
public void clearAllCache() {
clearAllCachedAuthenticationInfo();
clearAllCachedAuthorizationInfo();
}
}

修改UserController

在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
50
51
52
53
@RestController
@RequestMapping("userInfo")
public class UserController {

@Autowired
private UserService userService;

@Autowired
private RoleService roleService;

.....

/**
* 给admin用户添加 userInfo:del 权限
* @param model
* @return
*/
@RequestMapping(value = "/addPermission",method = RequestMethod.GET)
@ResponseBody
public String addPermission(Model model) {

//在sys_role_permission 表中 将 删除的权限 关联到admin用户所在的角色
roleService.addPermission(1,3);

//添加成功之后 清除缓存
DefaultWebSecurityManager securityManager = (DefaultWebSecurityManager)SecurityUtils.getSecurityManager();
ShiroRealm shiroRealm = (ShiroRealm) securityManager.getRealms().iterator().next();
//清除权限 相关的缓存
shiroRealm.clearAllCache();
return "给admin用户添加 userInfo:del 权限成功";

}

/**
* 删除admin用户 userInfo:del 权限
* @param model
* @return
*/
@RequestMapping(value = "/delPermission",method = RequestMethod.GET)
@ResponseBody
public String delPermission(Model model) {

//在sys_role_permission 表中 将 删除的权限 关联到admin用户所在的角色
roleService.delPermission(1,3);
//添加成功之后 清除缓存
DefaultWebSecurityManager securityManager = (DefaultWebSecurityManager) SecurityUtils.getSecurityManager();
ShiroRealm shiroRealm = (ShiroRealm) securityManager.getRealms().iterator().next();
//清除权限 相关的缓存
shiroRealm.clearAllCache();
return "删除admin用户userInfo:del 权限成功";
}

}

注意:在添加权限 或者 删除权限之后 都有调用shiroRealm.clearAllCache();来清除所有的缓存。

测试步骤:

  1. 两个浏览器: 一个谷歌浏览器登录 admin账户,另一个360浏览器登录 test账户,每个账户登录跳转到idnex页面之后 ,刷新两下,发现之后无论怎么刷新都不再打印查询数据库的sql

  2. 在谷歌浏览器地址调用添加权限的方法http://localhost:8080/userInfo/addPermission 显示添加完成,然后再次访问http://localhost:8080/index 页面上已经显示刚添加的权限,查看日志发现走数据库查询了最新的权限信息

  3. 这个时候在360浏览器刷新http://localhost:8080/index 查看控制台日志发现test的用户也有走数据库查询权限信息

shiro会话管理

​ Shiro提供了完整的企业级会话管理功能,不依赖于底层容器(如Tomcat),不管是J2SE还是J2EE环境都可以使用,提供了会话管理,会话事件监听,会话存储/持久化,容器无关的集群,失效/过期支持,对Web的透明支持,SSO单点登录的支持等特性。即直接使用 Shiro 的会话管理可以直接替换如 Web 容器的会话管理。

shiro中的session特性

  • 基于POJO/J2SE:shiro中session相关的类都是基于接口实现的简单的java对象(POJO),兼容所有java对象的配置方式,扩展也更方便,完全可以定制自己的会话管理功能 。
  • 简单灵活的会话存储/持久化:因为shiro中的session对象是基于简单的java对象的,所以你可以将session存储在任何地方,例如,文件,各种数据库,内存中等。
  • 容器无关的集群功能:shiro中的session可以很容易的集成第三方的缓存产品完成集群的功能。例如,Ehcache + Terracotta, Coherence, GigaSpaces等。你可以很容易的实现会话集群而无需关注底层的容器实现。
  • 异构客户端的访问:可以实现web中的session和非web项目中的session共享。
  • 会话事件监听:提供对对session整个生命周期的监听。
  • 保存主机地址:在会话开始session会存用户的ip地址和主机名,以此可以判断用户的位置。
  • 会话失效/过期的支持:用户长时间处于不活跃状态可以使会话过期,调用touch()方法,可以主动更新最后访问时间,让会话处于活跃状态。
  • 透明的Web支持:shiro全面支持Servlet 2.5中的session规范。这意味着你可以将你现有的web程序改为shiro会话,而无需修改代码。
  • 单点登录的支持:shiro session基于普通java对象,使得它更容易存储和共享,可以实现跨应用程序共享。可以根据共享的会话,来保证认证状态到另一个程序。从而实现单点登录。

会话相关API

获取Session的方式
1
2
Subject subject = SecurityUtils.getSubject();
Session session = subject.getSession();

与web中的 HttpServletRequest.getSession(boolean create) 类似!
Subject.getSession(true)。即如果当前没有创建session对象会创建一个;
Subject.getSession(false),如果当前没有创建session对象则返回null。

Session相关API
返回值 方法名 描述
Object getAttribute(Object key) 根据key标识返回绑定到session的对象
Collection getAttributeKeys() 获取在session中存储的所有的key
String getHost() 获取当前主机ip地址,如果未知,返回null
Serializable getId() 获取session的唯一id
Date getLastAccessTime() 获取最后的访问时间
Date getStartTimestamp() 获取session的启动时间
long getTimeout() 获取session失效时间,单位毫秒
void setTimeout(long maxIdleTimeInMillis) 设置session的失效时间
Object removeAttribute(Object key) 通过key移除session中绑定的对象
void setAttribute(Object key, Object value) 设置session会话属性
void stop() 销毁会话
void touch() 更新会话最后访问时间

会话管理器

会话管理器管理着应用中所有Subject的会话的创建、维护、删除、失效、验证等工作。是Shiro的核心组件,顶层组件SecurityManager直接继承了SessionManager,且提供了SessionsSecurityManager实现直接把会话管理委托给相应的SessionManager,DefaultSecurityManager及DefaultWebSecurityManager默认SecurityManager都继承了SessionsSecurityManager。

SecurityManager提供了如下接口:

  • Session start(SessionContext context); //启动会话

  • Session getSession(SessionKey key) throws SessionException;//根据会话Key获取会话

另外用于Web环境的WebSessionManager又提供了如下接口:

  • boolean isServletContainerSessions();//是否使用Servlet容器的会话

Shiro还提供了ValidatingSessionManager用于验资并过期会话:

  • void validateSessions();//验证所有会话是否过期
Shiro提供了三个默认实现

DefaultSessionManager

​ DefaultSecurityManager使用的默认实现,用于JavaSE环境;

ServletContainerSessionManager

​ DefaultWebSecurityManager使用的默认实现,用于Web环境,其直接使用Servlet容器的会话;

DefaultWebSessionManager

​ 用于Web环境的实现,可以替代ServletContainerSessionManager,自己维护着会话,直接废弃了Servlet容器的会话管理。

shiro配置会话管理配置

SessionListener

创建session监听类,实现SessionListener接口

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
public class ShiroSessionListener implements SessionListener {

/**
* 统计在线人数
* juc包下线程安全自增
*/
private final AtomicInteger sessionCount = new AtomicInteger(0);

/**
* 会话创建时触发
*
* @param session
*/
@Override
public void onStart(Session session) {
//会话创建,在线人数加一
sessionCount.incrementAndGet();
}

/**
* 退出会话时触发
*
* @param session
*/
@Override
public void onStop(Session session) {
//会话退出,在线人数减一
sessionCount.decrementAndGet();
}

/**
* 会话过期时触发
*
* @param session
*/
@Override
public void onExpiration(Session session) {
//会话过期,在线人数减一
sessionCount.decrementAndGet();
}

/**
* 获取在线人数使用
*
* @return
*/
public AtomicInteger getSessionCount() {
return sessionCount;
}
}

配置ShiroConfig

配置session监听
1
2
3
4
5
6
7
8
9
10
/**
* 配置session监听
*
* @return
*/
@Bean("sessionListener")
public ShiroSessionListener sessionListener() {
ShiroSessionListener sessionListener = new ShiroSessionListener();
return sessionListener;
}
配置会话ID生成器
1
2
3
4
5
6
7
8
9
/**
* 配置会话ID生成器
*
* @return
*/
@Bean
public SessionIdGenerator sessionIdGenerator() {
return new JavaUuidSessionIdGenerator();
}
配置sessionDAO

SessionDAO的作用是为Session提供CRUD并进行持久化的一个shiro组件

  • MemorySessionDAO 直接在内存中进行会话维护
  • EnterpriseCacheSessionDAO 提供了缓存功能的会话维护,默认情况下使用MapCache实现,内部使用ConcurrentHashMap保存缓存的会话。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/**
* SessionDAO的作用是为Session提供CRUD并进行持久化的一个shiro组件
* MemorySessionDAO 直接在内存中进行会话维护
* EnterpriseCacheSessionDAO 提供了缓存功能的会话维护,默认情况下使用MapCache实现,内部使用ConcurrentHashMap保存缓存的会话。
*
* @return
*/
@Bean
public SessionDAO sessionDAO() {
EnterpriseCacheSessionDAO enterpriseCacheSessionDAO = new EnterpriseCacheSessionDAO();
//使用ehCacheManager
enterpriseCacheSessionDAO.setCacheManager(ehCacheManager());
//设置session缓存的名字 默认为 shiro-activeSessionCache
enterpriseCacheSessionDAO.setActiveSessionsCacheName("shiro-activeSessionCache");
//sessionId生成器
enterpriseCacheSessionDAO.setSessionIdGenerator(sessionIdGenerator());
return enterpriseCacheSessionDAO;
}
配置sessionId的Cookie
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/**
* 配置保存sessionId的cookie
* 注意:这里的cookie 不是上面的记住我 cookie 记住我需要一个cookie session管理 也需要自己的cookie
*
* @return
*/
@Bean("sessionIdCookie")
public SimpleCookie sessionIdCookie() {
//这个参数是cookie的名称
SimpleCookie simpleCookie = new SimpleCookie("session_id");
//setcookie的httponly属性如果设为true的话,会增加对xss防护的安全系数。它有以下特点:

//setcookie()的第七个参数
//设为true后,只能通过http访问,javascript无法访问
//防止xss读取cookie
simpleCookie.setHttpOnly(true);
simpleCookie.setPath("/");
//maxAge=-1表示浏览器关闭时失效此Cookie
simpleCookie.setMaxAge(-1);
return simpleCookie;
}
配置session管理器
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
/**
* 配置会话管理器,设定会话超时及保存
* @return
*/
@Bean("sessionManager")
public SessionManager sessionManager() {

DefaultWebSessionManager sessionManager = new DefaultWebSessionManager();
Collection<SessionListener> listeners = new ArrayList<SessionListener>();
//配置监听
listeners.add(sessionListener());
sessionManager.setSessionListeners(listeners);
sessionManager.setSessionIdCookie(sessionIdCookie());
sessionManager.setSessionDAO(sessionDAO());
sessionManager.setCacheManager(ehCacheManager());

//全局会话超时时间(单位毫秒),默认30分钟 暂时设置为10秒钟 用来测试
sessionManager.setGlobalSessionTimeout(1800000);
//是否开启删除无效的session对象 默认为true
sessionManager.setDeleteInvalidSessions(true);
//是否开启定时调度器进行检测过期session 默认为true
sessionManager.setSessionValidationSchedulerEnabled(true);
//设置session失效的扫描时间, 清理用户直接关闭浏览器造成的孤立会话 默认为 1个小时
//设置该属性 就不需要设置 ExecutorServiceSessionValidationScheduler 底层也是默认自动调用ExecutorServiceSessionValidationScheduler
//暂时设置为 5秒 用来测试
sessionManager.setSessionValidationInterval(3600000);

//取消url 后面的 JSESSIONID
sessionManager.setSessionIdUrlRewritingEnabled(false);

return sessionManager;
}

注意:这里的SessionIdCookie 是新建的一个SimpleCookie对象,不是之前整合记住我的那个rememberMeCookie 如果配错了,就会出现session经典问题:每次请求都是一个新的session 并且后台报以下异常,解析的时候报错.因为记住我cookie是加密的用户信息,所以报解密错误

1
org.apache.shiro.crypto.CryptoException: Unable to execute 'doFinal' with cipher instance [javax.crypto.Cipher@461df537].
配置清理孤立session

以上整合会话管理,还有一个问题: 如果用户如果不点注销,直接关闭浏览器,不能够进行session的清空处理,所以为了防止这样的问题,还需要增加有一个会话的验证调度。

1
2
3
4
5
6
7
8
9
10
//全局会话超时时间(单位毫秒),默认30分钟  暂时设置为10秒钟 用来测试
sessionManager.setGlobalSessionTimeout(1800000);
//是否开启删除无效的session对象 默认为true
sessionManager.setDeleteInvalidSessions(true);
//是否开启定时调度器进行检测过期session 默认为true
sessionManager.setSessionValidationSchedulerEnabled(true);
//设置session失效的扫描时间, 清理用户直接关闭浏览器造成的孤立会话 默认为 1个小时
//设置该属性 就不需要设置 ExecutorServiceSessionValidationScheduler 底层也是默认自动调用ExecutorServiceSessionValidationScheduler
//暂时设置为 5秒 用来测试
sessionManager.setSessionValidationInterval(3600000);
取消URL的JSESSIONID

shiro取消url上面的JSESSIONID

1
2
//取消url 后面的 JSESSIONID
sessionManager.setSessionIdUrlRewritingEnabled(false);
修改SecurityManager

将session管理器交给SecurityManager

1
2
3
4
5
6
7
8
9
10
11
12
13
14
/**
* 配置核心安全事务管理器
*
* @return
*/
@Bean(name = "securityManager")
public SecurityManager securityManager() {
...

//配置自定义session管理
securityManager.setSessionManager(sessionManager());

return securityManager;
}

配置Ehcache缓存

ehcache-shiro.xml添加session缓存属性

1
2
3
4
5
6
7
8
9
<!-- session缓存 -->
<cache name="shiro-activeSessionCache"
maxEntriesLocalHeap="2000"
eternal="false"
timeToIdleSeconds="0"
timeToLiveSeconds="0"
overflowToDisk="false"
statistics="true">
</cache>

注意: 这里一定要注意缓存的设置过期时间,还有setGlobalSessionTimeout 的值,任一个时间设置的比较短,session就会从ehcache中清除,到时候就会报There is no session with id [2e9e317f-7575-4bb0-98c4-3e6e5d2578f5]

启动测试

配置完成之后启动测试,登陆的时候点击 rememberMe 查看cookie 可以看到一个sessionId 和 一个记住我cookie

在线人数以及并发登录人数控制

​ 项目中有时候会遇到统计当前在线人数的需求,也有这种情况当A 用户在邯郸地区登录 ,然后A用户在北京地区再登录 ,要踢出邯郸登录的状态。如果用户在北京重新登录,那么又要踢出邯郸的用户,这样反复。
​ 这样保证了一个帐号只能同时一个人使用。那么下面来讲解一下 Shiro 怎么实现在线人数统计 以及 并发人数控制这个功能。

并发人数控制

​ 使用的技术其实是 shiro的自定义filter,在 springboot整合shiro -快速入门 中 我们已经了解到,在shiroConfig的ShiroFilterFactoryBean中使用的过滤规则,如:anon ,authc,user等本质上是通过调用各自对应的filter方式集成的,也就是说,它是遵循过滤器链规则的。

创建KickoutSessionControlFilter

写一个KickoutSessionControlFilter类继承AccessControlFilter类

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
/**
* shiro 自定义filter 实现 并发登录控制
*/
public class KickoutSessionControlFilter extends AccessControlFilter {

/**
* 踢出后到的地址
*/
private String kickoutUrl;

/**
* 踢出之前登录的/之后登录的用户 默认踢出之前登录的用户
*/
private boolean kickoutAfter = false;

/**
* 同一个帐号最大会话数 默认1
*/
private int maxSession = 1;
private SessionManager sessionManager;
private Cache<String, Deque<Serializable>> cache;

public void setKickoutUrl(String kickoutUrl) {
this.kickoutUrl = kickoutUrl;
}

public void setKickoutAfter(boolean kickoutAfter) {
this.kickoutAfter = kickoutAfter;
}

public void setMaxSession(int maxSession) {
this.maxSession = maxSession;
}

public void setSessionManager(SessionManager sessionManager) {
this.sessionManager = sessionManager;
}

public void setCacheManager(CacheManager cacheManager) {
this.cache = cacheManager.getCache("shiro-activeSessionCache");
}

/**
* 是否允许访问,返回true表示允许
*/
@Override
protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) throws Exception {
return false;
}

/**
* 表示访问拒绝时是否自己处理,如果返回true表示自己不处理且继续拦截器链执行,返回false表示自己已经处理了(比如重定向到另一个页面)。
*/
@Override
protected boolean onAccessDenied(ServletRequest request, ServletResponse response) throws Exception {
Subject subject = getSubject(request, response);
if (!subject.isAuthenticated() && !subject.isRemembered()) {
//如果没有登录,直接进行之后的流程
return true;
}

Session session = subject.getSession();
//这里获取的User是实体 因为我在 自定义ShiroRealm中的doGetAuthenticationInfo方法中
//new SimpleAuthenticationInfo(user, password, getName()); 传的是 User实体 所以这里拿到的也是实体,如果传的是userName 这里拿到的就是userName
String username = ((User) subject.getPrincipal()).getUsername();
Serializable sessionId = session.getId();

// 初始化用户的队列放到缓存里
Deque<Serializable> deque = cache.get(username);
if (deque == null) {
deque = new LinkedList<Serializable>();
cache.put(username, deque);
}

//如果队列里没有此sessionId,且用户没有被踢出;放入队列
if (!deque.contains(sessionId) && session.getAttribute("kickout") == null) {
deque.push(sessionId);
}

//如果队列里的sessionId数超出最大会话数,开始踢人
while (deque.size() > maxSession) {
Serializable kickoutSessionId = null;
if (kickoutAfter) { //如果踢出后者
kickoutSessionId = deque.getFirst();
kickoutSessionId = deque.removeFirst();
} else { //否则踢出前者
kickoutSessionId = deque.removeLast();
}
try {
Session kickoutSession = sessionManager.getSession(new DefaultSessionKey(kickoutSessionId));
if (kickoutSession != null) {
//设置会话的kickout属性表示踢出了
kickoutSession.setAttribute("kickout", true);
}
} catch (Exception e) {//ignore exception
e.printStackTrace();
}
}

//如果被踢出了,直接退出,重定向到踢出后的地址
if (session.getAttribute("kickout") != null) {
//会话被踢出了
try {
subject.logout();
} catch (Exception e) {
}
WebUtils.issueRedirect(request, response, kickoutUrl);
return false;
}
return true;
}
}

注意:我们首先看一下 isAccessAllowed() 方法,在这个方法中,如果返回 true,则表示“通过”,走到下一个过滤器。如果没有下一个过滤器的话,表示具有了访问某个资源的权限。如果返回 false,则会调用 onAccessDenied 方法,去实现相应的当过滤不通过的时候执行的操作,例如检查用户是否已经登陆过,如果登陆过,根据自定义规则选择踢出前一个用户 还是 后一个用户。
onAccessDenied方法 返回 true 表示 自己处理完成,然后继续拦截器链执行。
只有当两者都返回false时,才会终止后面的filter执行。

shiroConfig相关配置

并发登录控制配置
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/**
* 并发登录控制
* @return
*/
@Bean
public KickoutSessionControlFilter kickoutSessionControlFilter(){
KickoutSessionControlFilter kickoutSessionControlFilter = new KickoutSessionControlFilter();
//用于根据会话ID,获取会话进行踢出操作的;
kickoutSessionControlFilter.setSessionManager(sessionManager());
//使用cacheManager获取相应的cache来缓存用户登录的会话;用于保存用户—会话之间的关系的;
kickoutSessionControlFilter.setCacheManager(ehCacheManager());
//是否踢出后来登录的,默认是false;即后者登录的用户踢出前者登录的用户;
kickoutSessionControlFilter.setKickoutAfter(false);
//同一个用户最大的会话数,默认1;比如2的意思是同一个用户允许最多同时两个人登录;
kickoutSessionControlFilter.setMaxSession(1);
//被踢出后重定向到的地址;
kickoutSessionControlFilter.setKickoutUrl("/login?kickout=1");
return kickoutSessionControlFilter;
}
修改shirFilter

修改shiroConfig中shirFilter中配置KickoutSessionControlFilter 并修改过滤规则

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
/**
* ShiroFilterFactoryBean 处理拦截资源文件问题。
* 注意:初始化ShiroFilterFactoryBean的时候需要注入:SecurityManager
* Web应用中,Shiro可控制的Web请求必须经过Shiro主过滤器的拦截
* @param securityManager
* @return
*/
@Bean(name = "shirFilter")
public ShiroFilterFactoryBean shiroFilter(@Qualifier("securityManager") SecurityManager securityManager) {

ShiroFilterFactoryBean shiroFilterFactoryBean = new ShiroFilterFactoryBean();

......

//自定义拦截器限制并发人数,参考博客
LinkedHashMap<String, Filter> filtersMap = new LinkedHashMap<>();
//限制同一帐号同时在线的个数
filtersMap.put("kickout", kickoutSessionControlFilter());
shiroFilterFactoryBean.setFilters(filtersMap);

// 配置访问权限 必须是LinkedHashMap,因为它必须保证有序
// 过滤链定义,从上向下顺序执行,一般将 /**放在最为下边 --> : 这是一个坑,一不小心代码就不好使了
LinkedHashMap<String, String> filterChainDefinitionMap = new LinkedHashMap<>();
//配置不登录可以访问的资源,anon 表示资源都可以匿名访问
//配置记住我或认证通过可以访问的地址
filterChainDefinitionMap.put("/login", "kickout,anon");

......

//其他资源都需要认证 authc 表示需要认证才能进行访问 user表示配置记住我或认证通过可以访问的地址
filterChainDefinitionMap.put("/**", "kickout,user");

return shiroFilterFactoryBean;
}

解释: filterChainDefinitionMap.put("/**", "kickout,user"); 表示 访问/**下的资源 首先要通过 kickout 后面的filter,然后再通过user后面对应的filter才可以访问。

修改login.html

修改login.html添加踢出登录的信息提示

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
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org"
xmlns:sec="http://www.thymeleaf.org/thymeleaf-extras-springsecurity3"
xmlns:shiro="http://www.pollix.at/thymeleaf/shiro">
<head>
<meta charset="UTF-8"/>
<title>Insert title here</title>
</head>
<body>
<h1>欢迎登录</h1>
<h1 th:if="${msg != null }" th:text="${msg}" style="color: red"></h1>
<form action="/login" method="post">
用户名:<input type="text" name="username"/><br/>
&nbsp;&nbsp;码:<input type="password" name="password"/><br/>
<input type="checkbox" name="rememberMe"/>记住我<br/>
<input type="submit" value="提交"/>
</form>
</body>
<script src="https://cdn.bootcdn.net/ajax/libs/jquery/3.5.0/jquery.js"></script>
<script type="text/javascript">
$(function () {
var href = location.href;
if (href.indexOf("kickout") > 0) {
alert("您的账号在另一台设备上登录,如非本人操作,请立即修改密码!");
}
});
</script>
</html>

测试

先通过admin登录,然后换一个浏览器 再次登录admin,然后再回到原来的 浏览器刷新页面,会弹出框提示已下线

统计在线人数

​ springboot整合shiro-session管理 博客中,我们有配置过一个监听类 ,在该类中有统计session创建个数,我们也就用session的个数来统计在线的人数,但是这个统计人数是不准确的,存在这样一种情况,用户登录之后,强制退出浏览器,再次打开浏览器重新登录,在线人数一直在增加。暂时也没有想到特别好的方案。

修改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
@RestController
@RequestMapping("userInfo")
public class UserController {

@Autowired
private UserService userService;

@Autowired
private RoleService roleService;

@Autowired
private ShiroSessionListener shiroSessionListener;
.....

/**
* 当前登录用户数
*
* @return
*/
@RequestMapping(value = "/userCount", method = RequestMethod.GET)
@ResponseBody
public String getCurrentUserCount() {
int userCount = shiroSessionListener.getSessionCount();
return "当前在线人数" + userCount + "人";
}
}
测试

登录多个账户访问 http://localhost:8080/userInfo/userCount

登录失败次数限制

​ 如何限制用户登录尝试次数,防止坏人多次尝试,恶意暴力破解密码的情况出现,要限制用户登录尝试次数,必然要对用户名密码验证失败做记录,Shiro中用户名密码的验证交给了CredentialsMatcher 所以在CredentialsMatcher里面检查,记录登录次数是最简单的做法。当登录失败次数达到限制,修改数据库中的状态字段,并返回前台错误信息。
​ 因为之前的博客都是用的明文,这里就不对密码进行加密了,如果有需要加密,将自定义密码比较器从SimpleCredentialsMatcher改为HashedCredentialsMatcher 然后将对应的配置项打开就可以。

限制登录次数

自定义RetryLimitHashedCredentialsMatcher继承SimpleCredentialsMatcher

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
public class RetryLimitHashedCredentialsMatcher extends SimpleCredentialsMatcher {

private static final Logger logger = LoggerFactory.getLogger(RetryLimitHashedCredentialsMatcher.class);

private UserServiceAgent userServiceAgent = new UserServiceAgent();

private Cache<String, AtomicInteger> passwordRetryCache;

public RetryLimitHashedCredentialsMatcher(CacheManager cacheManager) {
passwordRetryCache = cacheManager.getCache("passwordRetryCache");
}

@Override
public boolean doCredentialsMatch(AuthenticationToken token, AuthenticationInfo info) {

//获取用户名
String username = (String) token.getPrincipal();
//获取用户登录次数
AtomicInteger retryCount = passwordRetryCache.get(username);
if (retryCount == null) {
//如果用户没有登陆过,登陆次数加1 并放入缓存
retryCount = new AtomicInteger(0);
passwordRetryCache.put(username, retryCount);
}
if (retryCount.incrementAndGet() > 5) {
//如果用户登陆失败次数大于5次 抛出锁定用户异常 并修改数据库字段
User user = userServiceAgent.findByUserName(username);
if (user != null && "0".equals(user.getState())) {
//数据库字段 默认为 0 就是正常状态 所以 要改为1
//修改数据库的状态字段为锁定
user.setState("1");
userServiceAgent.update(user);
}
logger.info("锁定用户" + user.getUsername());
//抛出用户锁定异常
throw new LockedAccountException();
}
//判断用户账号和密码是否正确
boolean matches = super.doCredentialsMatch(token, info);
if (matches) {
//如果正确,从缓存中将用户登录计数 清除
passwordRetryCache.remove(username);
}
return matches;
}

/**
* 根据用户名 解锁用户
*
* @param username
*/

public void unlockAccount(String username) {
User user = userServiceAgent.findByUserName(username);
if (user != null) {
//修改数据库的状态字段为锁定
user.setState("0");
userServiceAgent.update(user);
passwordRetryCache.remove(username);
}
}
}

shiroConfig配置

配置密码比较器
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
/**
* 配置密码比较器
*
* @return
*/

@Bean("credentialsMatcher")
public RetryLimitHashedCredentialsMatcher retryLimitHashedCredentialsMatcher() {
RetryLimitHashedCredentialsMatcher retryLimitHashedCredentialsMatcher = new RetryLimitHashedCredentialsMatcher(ehCacheManager());

//如果密码加密,可以打开下面配置
//加密算法的名称
//retryLimitHashedCredentialsMatcher.setHashAlgorithmName("MD5");
//配置加密的次数
//retryLimitHashedCredentialsMatcher.setHashIterations(1024);
//是否存储为16进制
//retryLimitHashedCredentialsMatcher.setStoredCredentialsHexEncoded(true);

return retryLimitHashedCredentialsMatcher;
}
shiroRealm中配置密码比较器
1
2
3
4
5
6
7
8
@Bean
public ShiroRealm shiroRealm() {
ShiroRealm shiroRealm = new ShiroRealm();
.....
//配置自定义密码比较器
shiroRealm.setCredentialsMatcher(retryLimitHashedCredentialsMatcher());
return shiroRealm;
}

配置缓存

在ehcache-shiro.xml添加缓存项

1
2
3
4
5
6
7
8
9
10
11
12
13
<!-- 登录失败次数缓存
注意 timeToLiveSeconds 设置为300秒 也就是5分钟
可以根据自己的需求更改
-->
<cache name="passwordRetryCache"
maxEntriesLocalHeap="2000"
eternal="false"
timeToIdleSeconds="0"
timeToLiveSeconds="300"
overflowToDisk="false"
statistics="true">
</cache>

修改LoginController

在LoginController中添加解除admin用户限制方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@Controller
public class LoginController {

@Autowired
private RetryLimitHashedCredentialsMatcher retryLimitHashedCredentialsMatcher;

.....
/**
* 解除admin 用户的限制登录
* 写死的 方便测试
*
* @return
*/

@RequestMapping("/unlockAccount")
public String unlockAccount(Model model) {
model.addAttribute("msg", "用户解锁成功");
retryLimitHashedCredentialsMatcher.unlockAccount("admin");
return "login";
}

}

注意:为了方便测试,记得将 unlockAccount 权限改为 任何人可访问。

修改login.html

在login.html页面 添加 解锁admin用户的按钮

1
<a href="/unlockAccount">解锁admin用户</a></button>

测试

连续五次输错密码

实现验证码认证

​ 验证码是有效防止暴力破解的一种手段,常用做法是在服务端产生一串随机字符串与当前用户会话关联(我们通常说的放入 Session),然后向终端用户展现一张经过“扰乱”的图片,只有当用户输入的内容与服务端产生的内容相同时才允许进行下一步操作.

shiro添加验证码

​ 说的是shiro添加验证码,其实不如说是web服务登录功能添加验证码,因为这个功能和shiro完全没有任何关系,网上大都是 实现 FormAuthenticationFilter 然后 在filter中进行验证码校验,或者是 在shiroRealm中进行验证码校验,一大堆的代码要写,感觉很麻烦,下面进行最简单的代码实现验证码功能。直接在 login方法内,判断验证码是否正确。

创建验证码类

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
package com.demo.utils;

import java.awt.*;
import java.awt.image.BufferedImage;
import java.util.Random;

/**
* 验证码工具类
*/
public class CaptchaUtil {

// 随机产生的字符串
private static final String RANDOM_STRS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ";

private static final String FONT_NAME = "Fixedsys";
private static final int FONT_SIZE = 18;

private Random random = new Random();

private int width = 80;// 图片宽
private int height = 25;// 图片高
private int lineNum = 50;// 干扰线数量
private int strNum = 4;// 随机产生字符数量

/**
* 生成随机图片
*/
public BufferedImage genRandomCodeImage(StringBuffer randomCode) {
// BufferedImage类是具有缓冲区的Image类
BufferedImage image = new BufferedImage(width, height,
BufferedImage.TYPE_INT_BGR);
// 获取Graphics对象,便于对图像进行各种绘制操作
Graphics g = image.getGraphics();
// 设置背景色
g.setColor(getRandColor(200, 250));
g.fillRect(0, 0, width, height);

// 设置干扰线的颜色
g.setColor(getRandColor(110, 120));

// 绘制干扰线
for (int i = 0; i <= lineNum; i++) {
drowLine(g);
}
// 绘制随机字符
g.setFont(new Font(FONT_NAME, Font.ROMAN_BASELINE, FONT_SIZE));
for (int i = 1; i <= strNum; i++) {
randomCode.append(drowString(g, i));
}
g.dispose();
return image;
}

/**
* 给定范围获得随机颜色
*/
private Color getRandColor(int fc, int bc) {
if (fc > 255) {
fc = 255;
}
if (bc > 255) {
bc = 255;
}
int r = fc + random.nextInt(bc - fc);
int g = fc + random.nextInt(bc - fc);
int b = fc + random.nextInt(bc - fc);
return new Color(r, g, b);
}

/**
* 绘制字符串
*/
private String drowString(Graphics g, int i) {
g.setColor(new Color(random.nextInt(101), random.nextInt(111), random
.nextInt(121)));
String rand = String.valueOf(getRandomString(random.nextInt(RANDOM_STRS
.length())));
g.translate(random.nextInt(3), random.nextInt(3));
g.drawString(rand, 13 * i, 16);
return rand;
}

/**
* 绘制干扰线
*/
private void drowLine(Graphics g) {
int x = random.nextInt(width);
int y = random.nextInt(height);
int x0 = random.nextInt(16);
int y0 = random.nextInt(16);
g.drawLine(x, y, x + x0, y + y0);
}

/**
* 获取随机的字符
*/
private String getRandomString(int num) {
return String.valueOf(RANDOM_STRS.charAt(num));
}
}

获取验证码Controller

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
@Controller
public class CaptchaController {

public static final String KEY_CAPTCHA = "KEY_CAPTCHA";

@RequestMapping("/Captcha.jpg")
public void getCaptcha(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
// 设置相应类型,告诉浏览器输出的内容为图片
response.setContentType("image/jpeg");
// 不缓存此内容
response.setHeader("Pragma", "No-cache");
response.setHeader("Cache-Control", "no-cache");
response.setDateHeader("Expire", 0);
try {

HttpSession session = request.getSession();

CaptchaUtil tool = new CaptchaUtil();
StringBuffer code = new StringBuffer();
BufferedImage image = tool.genRandomCodeImage(code);
session.removeAttribute(KEY_CAPTCHA);
session.setAttribute(KEY_CAPTCHA, code.toString());

// 将内存中的图片通过流动形式输出到客户端
ImageIO.write(image, "JPEG", response.getOutputStream());

} catch (Exception e) {
e.printStackTrace();
}
}
}

修改shiroConfig

在shiroConfig中对 获取验证码的功能开放权限

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Bean(name = "shirFilter")
public ShiroFilterFactoryBean shiroFilter(@Qualifier("securityManager") SecurityManager securityManager) {

ShiroFilterFactoryBean shiroFilterFactoryBean = new ShiroFilterFactoryBean();
.....

// 配置访问权限 必须是LinkedHashMap,因为它必须保证有序
// 过滤链定义,从上向下顺序执行,一般将 /**放在最为下边 一定要注意顺序,否则就不好使了
LinkedHashMap<String, String> filterChainDefinitionMap = new LinkedHashMap<>();
//配置不登录可以访问的资源,anon 表示资源都可以匿名访问
filterChainDefinitionMap.put("/Captcha.jpg","anon");
filterChainDefinitionMap.put("/login", "kickout,anon");
filterChainDefinitionMap.put("/", "anon");

.....
return shiroFilterFactoryBean;
}

修改LoginController

在login方法内添加验证码校验

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@RequestMapping(value = "/login", method = RequestMethod.POST)
public String loginUser(HttpServletRequest request, String username, String password, boolean rememberMe, String captcha, Model model, HttpSession session) {

//校验验证码
//session中的验证码
String sessionCaptcha = (String) SecurityUtils.getSubject().getSession().getAttribute(CaptchaController.KEY_CAPTCHA);
if (null == captcha || !captcha.equalsIgnoreCase(sessionCaptcha)) {
model.addAttribute("msg", "验证码错误!");
return "login";
}


//对密码进行加密
//password=new SimpleHash("md5", password, ByteSource.Util.bytes(username.toLowerCase() + "shiro"),2).toHex();
//如果有点击 记住我
UsernamePasswordToken usernamePasswordToken = new UsernamePasswordToken(username, password, rememberMe);
.....
}

修改login.html

登录页面添加验证码

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
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org"
xmlns:sec="http://www.thymeleaf.org/thymeleaf-extras-springsecurity3"
xmlns:shiro="http://www.pollix.at/thymeleaf/shiro">
<head>
<meta charset="UTF-8"/>
<title>Insert title here</title>
</head>
<body>
<h1>欢迎登录</h1>
<h1 th:if="${msg != null }" th:text="${msg}" style="color: red"></h1>
<form action="/login" method="post">
用户名:<input type="text" name="username"/><br/>
&nbsp;&nbsp;码:<input type="password" name="password"/><br/>
验证码:<input type="text" name="captcha"/><img alt="验证码" th:src="@{/Captcha.jpg}" title="点击更换" id="captcha_img"/>
(看不清<a id="refreshCaptcha" href="javascript:void(0)">换一张</a>)<br/>
<input type="checkbox" name="rememberMe"/>记住我<br/>
<input type="submit" value="提交"/>
<a href="/unlockAccount">解锁admin用户</a></button>
</form>
</body>
<script src="https://cdn.bootcdn.net/ajax/libs/jquery/3.5.0/jquery.js"></script>
<script type="text/javascript">
$(function () {
var href = location.href;
if (href.indexOf("kickout") > 0) {
alert("您的账号在另一台设备上登录,如非本人操作,请立即修改密码!");
}

$("#refreshCaptcha,#captcha_img").click(function () {
$("#captcha_img").attr("src", "/Captcha.jpg?id=" + new Date() + Math.floor(Math.random() * 24));
});
});
</script>
</html>

测试

故意输错验证码

整合redis作为缓存

​ 本来的整合过程是顺着博客的顺序来的,越往下,集成的越多,由于之前是使用ehcache缓存,现在改为redis,限制登录人数 以及 限制登录次数等 都需要改动,本篇为了简单,目前先将这两个功能下线,配置暂时是注销的,原类保存,在下篇博客中改。
​ 还有之前是使用SessionListener监听session创建来统计在线人数,在本篇中也将改为统计redis中的key数目。
如果是单机,使用ehcache是最快的,项目一般都不是单节点,为了方便之后使用sso单点登录,以及多节点部署,所以使用shiro整合redis。

整合Redis

参考整合Redis

删除Ehcache

POM文件修改

去掉shiro-ehcache依赖

删除Ehcache配置

删除ehcache-shiro.xml

创建代理类

因为通过@Bean的方式初始化一些组件会产生业务Bean的污染,需要进行采用代理的方式屏蔽依赖污染。

ApplicationUtils

Application相关工具类,使用getBean的方式来获取实例。

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
public class ApplicationUtils {

private static ApplicationContext appContext = null;


public static void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
appContext = applicationContext;
}

public static Object getBean(String beanId) {
Object bean = null;
if (null != appContext) {
bean = appContext.getBean(beanId);
}
return bean;
}

public static <T> T getBean(Class clazz) {
Object bean = null;
if (null != appContext) {
bean = appContext.getBean(clazz);
}
return (T) bean;
}


public static UserMapper getUserMapper() {
return ApplicationUtils.getBean(UserMapper.class);
}

public static RoleMapper getRoleMapper() {
return ApplicationUtils.getBean(RoleMapper.class);
}

public static PermissionMapper getPermissionMapper() {
return ApplicationUtils.getBean(PermissionMapper.class);
}

public static RedisManagerImpl getRedisManager() {
return ApplicationUtils.getBean(RedisManagerImpl.class);
}
}
ApplicationContexConfig

实现applicationContext的启动注册

1
2
3
4
5
6
7
@Component
public class ApplicationContexConfig implements ApplicationContextAware {
@Override
public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
ApplicationUtils.setApplicationContext(applicationContext);
}
}
UserServiceAgent

用户操作的代理类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public class UserServiceAgent implements UserService {


@Override
public User findByUserName(String userName) {
return ApplicationUtils.getUserMapper().findByUserName(userName);
}

@Override
public int insert(User user) {
return ApplicationUtils.getUserMapper().insert(user);
}

@Override
public int update(User user) {
return ApplicationUtils.getUserMapper().update(user);
}

@Override
public int del(String username) {
return ApplicationUtils.getUserMapper().del(username);
}
}

RoleServiceAgent

角色代理类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class RoleServiceAgent implements RoleService {


@Override
public Set<Role> findRolesByUserId(Integer uid) {
return ApplicationUtils.getRoleMapper().findRolesByUserId(uid);
}

@Override
public void delPermission(int roleId, int permissionId) {
ApplicationUtils.getRoleMapper().delPermission(roleId, permissionId);
}

@Override
public void addPermission(int roleId, int permissionId) {
ApplicationUtils.getRoleMapper().addPermission(roleId, permissionId);
}
}
PermissionServiceAgent

权限代理类

1
2
3
4
5
6
7
public class PermissionServiceAgent implements PermissionService {

@Override
public Set<Permission> findPermissionsByRoleId(Set<Role> roles) {
return ApplicationUtils.getPermissionMapper().findPermissionsByRoleId(roles);
}
}
RedisManagerAgent

为了解决初始化bean 需要依赖redis 造成的 bean过早被初始化

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
public class RedisManagerAgent implements RedisManager {

/**
* 指定缓存失效时间
*
* @param key 键
* @param time 时间(秒)
*/
public void expire(String key, long time) {
ApplicationUtils.getRedisManager().expire(key, time);
}

/**
* 判断key是否存在
*
* @param key 键
* @return true 存在 false不存在
*/
public Boolean hasKey(String key) {
return ApplicationUtils.getRedisManager().hasKey(key);
}

/**
* 删除缓存
*
* @param key 可以传一个值 或多个
*/
@SuppressWarnings("unchecked")
public void del(String... key) {
ApplicationUtils.getRedisManager().del(key);
}

/**
* 批量删除key
*
* @param keys
*/
public void del(Collection keys) {
ApplicationUtils.getRedisManager().del(keys);
}

//============================String=============================

/**
* 普通缓存获取
*
* @param key 键
* @return
*/
public Object get(String key) {
return ApplicationUtils.getRedisManager().get(key);
}

/**
* 普通缓存放入
*
* @param key 键
* @param value 值
*/
public void set(String key, Object value) {
ApplicationUtils.getRedisManager().set(key, value);
}

/**
* 普通缓存放入并设置时间
*
* @param key 键
* @param value 值
* @param time 时间(秒) time要大于0 如果time小于等于0 将设置无限期
*/
public void set(String key, Object value, long time) {
ApplicationUtils.getRedisManager().set(key, value, time);
}

/**
* 使用scan命令 查询某些前缀的key
*
* @param key
* @return
*/
public Set<String> scan(String key) {
return ApplicationUtils.getRedisManager().scan(key);
}

/**
* 使用scan命令 查询某些前缀的key 有多少个
* 用来获取当前session数量,也就是在线用户
*
* @param key
* @return
*/
public Long scanSize(String key) {
return ApplicationUtils.getRedisManager().scanSize(key);
}
}

相关依赖类

ShiroSession

由于SimpleSession lastAccessTime更改后也会调用SessionDao update方法,更新的字段只有LastAccessTime(最后一次访问时间),由于会话失效是由Redis数据过期实现的,这个字段意义不大,为了减少对Redis的访问,降低网络压力,实现自己的Session,在SimpleSession上套一层,增加一个标识位,如果Session除lastAccessTime意外其它字段修改,就标识一下,只有标识为修改的才可以通过doUpdate访问Redis,否则直接返回。

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
public class ShiroSession extends SimpleSession implements Serializable {
// 除lastAccessTime以外其他字段发生改变时为true
private boolean isChanged = false;

public ShiroSession() {
super();
this.setChanged(true);
}

public ShiroSession(String host) {
super(host);
this.setChanged(true);
}


@Override
public void setId(Serializable id) {
super.setId(id);
this.setChanged(true);
}

@Override
public void setStopTimestamp(Date stopTimestamp) {
super.setStopTimestamp(stopTimestamp);
this.setChanged(true);
}

@Override
public void setExpired(boolean expired) {
super.setExpired(expired);
this.setChanged(true);
}

@Override
public void setTimeout(long timeout) {
super.setTimeout(timeout);
this.setChanged(true);
}

@Override
public void setHost(String host) {
super.setHost(host);
this.setChanged(true);
}

@Override
public void setAttributes(Map<Object, Object> attributes) {
super.setAttributes(attributes);
this.setChanged(true);
}

@Override
public void setAttribute(Object key, Object value) {
super.setAttribute(key, value);
this.setChanged(true);
}

@Override
public Object removeAttribute(Object key) {
this.setChanged(true);
return super.removeAttribute(key);
}

/**
* 停止
*/
@Override
public void stop() {
super.stop();
this.setChanged(true);
}

/**
* 设置过期
*/
@Override
protected void expire() {
this.stop();
this.setExpired(true);
}

public boolean isChanged() {
return isChanged;
}

public void setChanged(boolean isChanged) {
this.isChanged = isChanged;
}

@Override
public boolean equals(Object obj) {
return super.equals(obj);
}

@Override
protected boolean onEquals(SimpleSession ss) {
return super.onEquals(ss);
}

@Override
public int hashCode() {
return super.hashCode();
}

@Override
public String toString() {
return super.toString();
}
}
SessionInMemory
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class SessionInMemory {
private Session session;
private Date createTime;

public Session getSession() {
return session;
}

public void setSession(Session session) {
this.session = session;
}

public Date getCreateTime() {
return createTime;
}

public void setCreateTime(Date createTime) {
this.createTime = createTime;
}
}

ShiroSessionFactory
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
public class ShiroSessionFactory implements SessionFactory {
private static final Logger logger = LoggerFactory.getLogger(ShiroSessionFactory.class);

@Override
public Session createSession(SessionContext initData) {
ShiroSession session = new ShiroSession();
HttpServletRequest request = (HttpServletRequest)initData.get(DefaultWebSessionContext.class.getName() + ".SERVLET_REQUEST");
session.setHost(getIpAddress(request));
return session;
}

public static String getIpAddress(HttpServletRequest request) {
String localIP = "127.0.0.1";
String ip = request.getHeader("x-forwarded-for");
if (StringUtils.isBlank(ip) || (ip.equalsIgnoreCase(localIP)) || "unknown".equalsIgnoreCase(ip)) {
ip = request.getHeader("Proxy-Client-IP");
}
if (StringUtils.isBlank(ip) || (ip.equalsIgnoreCase(localIP)) || "unknown".equalsIgnoreCase(ip)) {
ip = request.getHeader("WL-Proxy-Client-IP");
}
if (StringUtils.isBlank(ip) || (ip.equalsIgnoreCase(localIP)) || "unknown".equalsIgnoreCase(ip)) {
ip = request.getRemoteAddr();
}
return ip;
}
}
ShiroSessionListener

创建Session监听器

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
public class ShiroSessionListener implements SessionListener {

/*
* 统计在线人数
* juc包下线程安全自增
*/

private final AtomicInteger sessionCount = new AtomicInteger(0);

/*
* 会话创建时触发
*
* @param session
*/
@Override
public void onStart(Session session) {
//会话创建,在线人数加一
sessionCount.incrementAndGet();
}

/**
* 退出会话时触发
*
* @param session
*/

@Override

public void onStop(Session session) {
//会话退出,在线人数减一
sessionCount.decrementAndGet();
}

/**
* 会话过期时触发
*
* @param session
*/

@Override

public void onExpiration(Session session) {
//会话过期,在线人数减一
sessionCount.decrementAndGet();
}

/**
* 获取在线人数使用
*
* @return
*/

public int getSessionCount() {
return sessionCount.get();
}
}
重写密码比较器
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
public class RetryLimitHashedCredentialsMatcher extends SimpleCredentialsMatcher {

private static final Logger logger = LoggerFactory.getLogger(RetryLimitHashedCredentialsMatcher.class);

public static final String DEFAULT_RETRYLIMIT_CACHE_KEY_PREFIX = "shiro:cache:retrylimit:";
private String keyPrefix = DEFAULT_RETRYLIMIT_CACHE_KEY_PREFIX;


private UserServiceAgent userServiceAgent = new UserServiceAgent();
private RedisManager redisManager;

public void setRedisManager(RedisManager redisManager) {
this.redisManager = redisManager;
}

private String getRedisKickoutKey(String username) {
return this.keyPrefix + username;
}

@Override
public boolean doCredentialsMatch(AuthenticationToken token, AuthenticationInfo info) {

//获取用户名
String username = (String) token.getPrincipal();
//获取用户登录次数
AtomicInteger retryCount = (AtomicInteger) redisManager.get(getRedisKickoutKey(username));
if (retryCount == null) {
//如果用户没有登陆过,登陆次数加1 并放入缓存
retryCount = new AtomicInteger(0);
}
if (retryCount.incrementAndGet() > 5) {
//如果用户登陆失败次数大于5次 抛出锁定用户异常 并修改数据库字段
User user = userServiceAgent.findByUserName(username);
if (user != null && "0".equals(user.getState())) {
//数据库字段 默认为 0 就是正常状态 所以 要改为1
//修改数据库的状态字段为锁定
user.setState("1");
userServiceAgent.update(user);
}
logger.info("锁定用户" + user.getUsername());
//抛出用户锁定异常
throw new LockedAccountException();
}
//判断用户账号和密码是否正确
boolean matches = super.doCredentialsMatch(token, info);
if (matches) {
//如果正确,从缓存中将用户登录计数 清除
redisManager.del(getRedisKickoutKey(username));
}
{
redisManager.set(getRedisKickoutKey(username), retryCount);
}
return matches;
}

/**
* 根据用户名 解锁用户
*
* @param username
* @return
*/
public void unlockAccount(String username) {
User user = userServiceAgent.findByUserName(username);
if (user != null) {
//修改数据库的状态字段为锁定
user.setState("0");
userServiceAgent.update(user);
redisManager.del(getRedisKickoutKey(username));
}
}
}

SerializeUtils

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
public class SerializeUtils implements RedisSerializer {

private static Logger logger = LoggerFactory.getLogger(SerializeUtils.class);

public static boolean isEmpty(byte[] data) {
return (data == null || data.length == 0);
}

/**
* 序列化
*
* @param object
* @return
* @throws SerializationException
*/
@Override
public byte[] serialize(Object object) throws SerializationException {
byte[] result = null;

if (object == null) {
return new byte[0];
}
try (
ByteArrayOutputStream byteStream = new ByteArrayOutputStream(128);
ObjectOutputStream objectOutputStream = new ObjectOutputStream(byteStream)
) {

if (!(object instanceof Serializable)) {
throw new IllegalArgumentException(SerializeUtils.class.getSimpleName() + " requires a Serializable payload " +
"but received an object of type [" + object.getClass().getName() + "]");
}

objectOutputStream.writeObject(object);
objectOutputStream.flush();
result = byteStream.toByteArray();
} catch (Exception ex) {
logger.error("Failed to serialize", ex);
}
return result;
}

/**
* 反序列化
*
* @param bytes
* @return
* @throws SerializationException
*/
@Override
public Object deserialize(byte[] bytes) throws SerializationException {

Object result = null;

if (isEmpty(bytes)) {
return null;
}

try (
ByteArrayInputStream byteStream = new ByteArrayInputStream(bytes);
ObjectInputStream objectInputStream = new ObjectInputStream(byteStream)
) {
result = objectInputStream.readObject();
} catch (Exception e) {
logger.error("Failed to deserialize", e);
}
return result;
}

}

重写并发登录控制

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
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
/**
* shiro 自定义filter 实现 并发登录控制
* <p>
* 踢出后到的地址
* <p>
* 踢出之前登录的/之后登录的用户 默认踢出之前登录的用户
* <p>
* 同一个帐号最大会话数 默认1
* <p>
* 是否允许访问,返回true表示允许
* <p>
* 表示访问拒绝时是否自己处理,如果返回true表示自己不处理且继续拦截器链执行,返回false表示自己已经处理了(比如重定向到另一个页面)。
*/

public class KickoutSessionControlFilter extends AccessControlFilter {


/**
* 踢出后到的地址
*/
private String kickoutUrl;

/**
* 踢出之前登录的/之后登录的用户 默认踢出之前登录的用户
*/
private boolean kickoutAfter = false;

/**
* 同一个帐号最大会话数 默认1
*/
private int maxSession = 1;
private SessionManager sessionManager;

private RedisManager redisManager;

public static final String DEFAULT_KICKOUT_CACHE_KEY_PREFIX = "shiro:cache:kickout:";
private String keyPrefix = DEFAULT_KICKOUT_CACHE_KEY_PREFIX;

public void setKickoutUrl(String kickoutUrl) {
this.kickoutUrl = kickoutUrl;
}

public void setKickoutAfter(boolean kickoutAfter) {
this.kickoutAfter = kickoutAfter;
}

public void setMaxSession(int maxSession) {
this.maxSession = maxSession;
}

public void setSessionManager(SessionManager sessionManager) {
this.sessionManager = sessionManager;
}

public void setRedisManager(RedisManager redisManager) {
this.redisManager = redisManager;
}

public String getKeyPrefix() {
return keyPrefix;
}

public void setKeyPrefix(String keyPrefix) {
this.keyPrefix = keyPrefix;
}

private String getRedisKickoutKey(String username) {
return this.keyPrefix + username;
}

/**
* 是否允许访问,返回true表示允许
*/
@Override
protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) throws Exception {
return false;
}

/**
* 表示访问拒绝时是否自己处理,如果返回true表示自己不处理且继续拦截器链执行,返回false表示自己已经处理了(比如重定向到另一个页面)。
*/
@Override
protected boolean onAccessDenied(ServletRequest request, ServletResponse response) throws Exception {
Subject subject = getSubject(request, response);
if (!subject.isAuthenticated() && !subject.isRemembered()) {
//如果没有登录,直接进行之后的流程
return true;
}

//如果有登录,判断是否访问的为静态资源,如果是游客允许访问的静态资源,直接返回true
HttpServletRequest httpServletRequest = (HttpServletRequest) request;
String path = httpServletRequest.getServletPath();
// 如果是静态文件,则返回true
if (isStaticFile(path)) {
return true;
}


Session session = subject.getSession();
//这里获取的User是实体 因为我在 自定义ShiroRealm中的doGetAuthenticationInfo方法中
//new SimpleAuthenticationInfo(user, password, getName()); 传的是 User实体 所以这里拿到的也是实体,如果传的是userName 这里拿到的就是userName
String username = ((User) subject.getPrincipal()).getUsername();
Serializable sessionId = session.getId();

// 初始化用户的队列放到缓存里
Deque<Serializable> deque = (Deque<Serializable>) redisManager.get(getRedisKickoutKey(username));
if (deque == null || deque.size() == 0) {
deque = new LinkedList<Serializable>();
}

//如果队列里没有此sessionId,且用户没有被踢出;放入队列
if (!deque.contains(sessionId) && session.getAttribute("kickout") == null) {
deque.push(sessionId);
}

//如果队列里的sessionId数超出最大会话数,开始踢人
while (deque.size() > maxSession) {
Serializable kickoutSessionId = null;
if (kickoutAfter) { //如果踢出后者
kickoutSessionId = deque.getFirst();
kickoutSessionId = deque.removeFirst();
} else { //否则踢出前者
kickoutSessionId = deque.removeLast();
}
try {
Session kickoutSession = sessionManager.getSession(new DefaultSessionKey(kickoutSessionId));
if (kickoutSession != null) {
//设置会话的kickout属性表示踢出了
kickoutSession.setAttribute("kickout", true);
}
} catch (Exception e) {//ignore exception
e.printStackTrace();
}
}

redisManager.set(getRedisKickoutKey(username), deque);

//如果被踢出了,直接退出,重定向到踢出后的地址
if (session.getAttribute("kickout") != null) {
//会话被踢出了
try {
subject.logout();
} catch (Exception e) {
}
WebUtils.issueRedirect(request, response, kickoutUrl);
return false;
}
return true;
}

private boolean isStaticFile(String path) {
return true;
}
}

配置Redis管理器

Redis 配置类
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
/**
* Redis 配置类
*/
@Configuration
public class RedisConfiguration extends CachingConfigurerSupport {


@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) {
RedisTemplate<String, Object> template = new RedisTemplate<>();
SerializeUtils serializeUtils = new SerializeUtils();
// value值的序列化采用JDK的序列化
template.setValueSerializer(serializeUtils);
template.setHashValueSerializer(serializeUtils);
// key的序列化采用StringRedisSerializer
template.setKeySerializer(new StringRedisSerializer());
template.setHashKeySerializer(new StringRedisSerializer());
template.setConnectionFactory(factory);
return template;
}
}

创建RedisManager接口
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

public interface RedisManager {


/**
* 指定缓存失效时间
*
* @param key 键
* @param time 时间(秒)
*/
public void expire(String key, long time);

/**
* 判断key是否存在
*
* @param key 键
* @return true 存在 false不存在
*/
public Boolean hasKey(String key);

/**
* 删除缓存
*
* @param key 可以传一个值 或多个
*/

public void del(String... key);

/**
* 批量删除key
*
* @param keys
*/
public void del(Collection keys);

//============================String=============================

/**
* 普通缓存获取
*
* @param key 键
* @return
*/
public Object get(String key);

/**
* 普通缓存放入
*
* @param key 键
* @param value 值
*/
public void set(String key, Object value);

/**
* 普通缓存放入并设置时间
*
* @param key 键
* @param value 值
* @param time 时间(秒) time要大于0 如果time小于等于0 将设置无限期
*/
public void set(String key, Object value, long time);

/**
* 使用scan命令 查询某些前缀的key
*
* @param key
* @return
*/
public Set<String> scan(String key);

/**
* 使用scan命令 查询某些前缀的key 有多少个
* 用来获取当前session数量,也就是在线用户
*
* @param key
* @return
*/
public Long scanSize(String key);
}

创建RedisManagerImpl
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
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
/**
* @author wangsaichao
* 基于spring和redis的redisTemplate工具类
*/
@Component("redisManager")
public class RedisManagerImpl implements RedisManager {

@Autowired
private RedisTemplate<String, Object> redisTemplate;

//=============================common============================

/**
* 指定缓存失效时间
*
* @param key 键
* @param time 时间(秒)
*/
public void expire(String key, long time) {
redisTemplate.expire(key, time, TimeUnit.SECONDS);
}

/**
* 判断key是否存在
*
* @param key 键
* @return true 存在 false不存在
*/
public Boolean hasKey(String key) {
return redisTemplate.hasKey(key);
}

/**
* 删除缓存
*
* @param key 可以传一个值 或多个
*/
@SuppressWarnings("unchecked")
public void del(String... key) {
if (key != null && key.length > 0) {
if (key.length == 1) {
redisTemplate.delete(key[0]);
} else {
redisTemplate.delete(CollectionUtils.arrayToList(key));
}
}
}

/**
* 批量删除key
*
* @param keys
*/
public void del(Collection keys) {
redisTemplate.delete(keys);
}

//============================String=============================

/**
* 普通缓存获取
*
* @param key 键
* @return
*/
public Object get(String key) {
return redisTemplate.opsForValue().get(key);
}

/**
* 普通缓存放入
*
* @param key 键
* @param value 值
*/
public void set(String key, Object value) {
redisTemplate.opsForValue().set(key, value);
}

/**
* 普通缓存放入并设置时间
*
* @param key 键
* @param value 值
* @param time 时间(秒) time要大于0 如果time小于等于0 将设置无限期
*/
public void set(String key, Object value, long time) {
if (time > 0) {
redisTemplate.opsForValue().set(key, value, time, TimeUnit.SECONDS);
} else {
set(key, value);
}
}

/**
* 使用scan命令 查询某些前缀的key
*
* @param key
* @return
*/
public Set<String> scan(String key) {
Set<String> execute = this.redisTemplate.execute(new RedisCallback<Set<String>>() {

@Override
public Set<String> doInRedis(RedisConnection connection) throws DataAccessException {

Set<String> binaryKeys = new HashSet<>();

Cursor<byte[]> cursor = connection.scan(new ScanOptions.ScanOptionsBuilder().match(key).count(1000).build());
while (cursor.hasNext()) {
binaryKeys.add(new String(cursor.next()));
}
return binaryKeys;
}
});
return execute;
}

/**
* 使用scan命令 查询某些前缀的key 有多少个
* 用来获取当前session数量,也就是在线用户
*
* @param key
* @return
*/
public Long scanSize(String key) {
long dbSize = this.redisTemplate.execute(new RedisCallback<Long>() {

@Override
public Long doInRedis(RedisConnection connection) throws DataAccessException {
long count = 0L;
Cursor<byte[]> cursor = connection.scan(ScanOptions.scanOptions().match(key).count(1000).build());
while (cursor.hasNext()) {
cursor.next();
count++;
}
return count;
}
});
return dbSize;
}
}
创建创建Redis缓存管理器
创建RedisCache
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
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
public class RedisCache<K, V> implements Cache<K, V> {

private static Logger logger = LoggerFactory.getLogger(RedisCache.class);

private RedisManager redisManager;
private String keyPrefix = "";
private int expire = 0;
private String principalIdFieldName = RedisCacheManager.DEFAULT_PRINCIPAL_ID_FIELD_NAME;

/**
* Construction
*
* @param redisManager
*/
public RedisCache(RedisManager redisManager, String prefix, int expire, String principalIdFieldName) {
if (redisManager == null) {
throw new IllegalArgumentException("redisManager cannot be null.");
}
this.redisManager = redisManager;
if (prefix != null && !"".equals(prefix)) {
this.keyPrefix = prefix;
}
if (expire != -1) {
this.expire = expire;
}
if (principalIdFieldName != null && !"".equals(principalIdFieldName)) {
this.principalIdFieldName = principalIdFieldName;
}
}

@Override
public V get(K key) throws CacheException {
logger.debug("get key [{}]", key);

if (key == null) {
return null;
}

try {
String redisCacheKey = getRedisCacheKey(key);
Object rawValue = redisManager.get(redisCacheKey);
if (rawValue == null) {
return null;
}
V value = (V) rawValue;
return value;
} catch (Exception e) {
throw new CacheException(e);
}
}

@Override
public V put(K key, V value) throws CacheException {
logger.debug("put key [{}]", key);
if (key == null) {
logger.warn("Saving a null key is meaningless, return value directly without call Redis.");
return value;
}
try {
String redisCacheKey = getRedisCacheKey(key);
redisManager.set(redisCacheKey, value != null ? value : null, expire);
return value;
} catch (Exception e) {
throw new CacheException(e);
}
}

@Override
public V remove(K key) throws CacheException {
logger.debug("remove key [{}]", key);
if (key == null) {
return null;
}
try {
String redisCacheKey = getRedisCacheKey(key);
Object rawValue = redisManager.get(redisCacheKey);
V previous = (V) rawValue;
redisManager.del(redisCacheKey);
return previous;
} catch (Exception e) {
throw new CacheException(e);
}
}

private String getRedisCacheKey(K key) {
if (key == null) {
return null;
}
return this.keyPrefix + getStringRedisKey(key);
}

private String getStringRedisKey(K key) {
String redisKey;
if (key instanceof PrincipalCollection) {
redisKey = getRedisKeyFromPrincipalIdField((PrincipalCollection) key);
} else {
redisKey = key.toString();
}
return redisKey;
}

private String getRedisKeyFromPrincipalIdField(PrincipalCollection key) {
String redisKey;
Object principalObject = key.getPrimaryPrincipal();
Method pincipalIdGetter = null;
Method[] methods = principalObject.getClass().getDeclaredMethods();
for (Method m : methods) {
if (RedisCacheManager.DEFAULT_PRINCIPAL_ID_FIELD_NAME.equals(this.principalIdFieldName)
&& ("getAuthCacheKey".equals(m.getName()) || "getId".equals(m.getName()))) {
pincipalIdGetter = m;
break;
}
if (m.getName().equals("get" + this.principalIdFieldName.substring(0, 1).toUpperCase() + this.principalIdFieldName.substring(1))) {
pincipalIdGetter = m;
break;
}
}
if (pincipalIdGetter == null) {
throw new PrincipalInstanceException(principalObject.getClass(), this.principalIdFieldName);
}

try {
Object idObj = pincipalIdGetter.invoke(principalObject);
if (idObj == null) {
throw new PrincipalIdNullException(principalObject.getClass(), this.principalIdFieldName);
}
redisKey = idObj.toString();
} catch (IllegalAccessException e) {
throw new PrincipalInstanceException(principalObject.getClass(), this.principalIdFieldName, e);
} catch (InvocationTargetException e) {
throw new PrincipalInstanceException(principalObject.getClass(), this.principalIdFieldName, e);
}

return redisKey;
}


@Override
public void clear() throws CacheException {
logger.debug("clear cache");
Set<String> keys = null;
try {
keys = redisManager.scan(this.keyPrefix + "*");
} catch (Exception e) {
logger.error("get keys error", e);
}
if (keys == null || keys.size() == 0) {
return;
}
for (String key : keys) {
redisManager.del(key);
}
}

@Override
public int size() {
Long longSize = 0L;
try {
longSize = new Long(redisManager.scanSize(this.keyPrefix + "*"));
} catch (Exception e) {
logger.error("get keys error", e);
}
return longSize.intValue();
}

@SuppressWarnings("unchecked")
@Override
public Set<K> keys() {
Set<String> keys = null;
try {
keys = redisManager.scan(this.keyPrefix + "*");
} catch (Exception e) {
logger.error("get keys error", e);
return Collections.emptySet();
}

if (CollectionUtils.isEmpty(keys)) {
return Collections.emptySet();
}

Set<K> convertedKeys = new HashSet<K>();
for (String key : keys) {
try {
convertedKeys.add((K) key);
} catch (Exception e) {
logger.error("deserialize keys error", e);
}
}
return convertedKeys;
}

@Override
public Collection<V> values() {
Set<String> keys = null;
try {
keys = redisManager.scan(this.keyPrefix + "*");
} catch (Exception e) {
logger.error("get values error", e);
return Collections.emptySet();
}

if (CollectionUtils.isEmpty(keys)) {
return Collections.emptySet();
}

List<V> values = new ArrayList<V>(keys.size());
for (String key : keys) {
V value = null;
try {
value = (V) redisManager.get(key);
} catch (Exception e) {
logger.error("deserialize values= error", e);
}
if (value != null) {
values.add(value);
}
}
return Collections.unmodifiableList(values);
}

public String getKeyPrefix() {
return keyPrefix;
}

public void setKeyPrefix(String keyPrefix) {
this.keyPrefix = keyPrefix;
}

public String getPrincipalIdFieldName() {
return principalIdFieldName;
}

public void setPrincipalIdFieldName(String principalIdFieldName) {
this.principalIdFieldName = principalIdFieldName;
}
}

​ getRedisKeyFromPrincipalIdField()是获取缓存的用户身份信息 和用户权限信息。 里面有一个属性principalIdFieldName 在RedisCacheManager也有这个属性,设置其中一个就可以.是为了给缓存用户身份和权限信息在Redis中的key唯一,登录用户名可能是username 或者 phoneNum 或者是Email中的一个,如 我的User实体类中 有一个 usernane字段,也是登录时候使用的用户名,在redis中缓存的权限信息key 如下, 这个admin 就是 通过getUsername获得的。

创建RedisCacheManager
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
public class RedisCacheManager implements CacheManager {

private final Logger logger = LoggerFactory.getLogger(RedisCacheManager.class);

/**
* fast lookup by name map
*/
private final ConcurrentMap<String, Cache> caches = new ConcurrentHashMap<String, Cache>();

private RedisManager redisManager;

/**
* expire time in seconds
*/
private static final int DEFAULT_EXPIRE = 1800;
private int expire = DEFAULT_EXPIRE;

/**
* The Redis key prefix for caches
*/
public static final String DEFAULT_CACHE_KEY_PREFIX = "shiro:cache:";
private String keyPrefix = DEFAULT_CACHE_KEY_PREFIX;

public static final String DEFAULT_PRINCIPAL_ID_FIELD_NAME = "authCacheKey or id";
private String principalIdFieldName = DEFAULT_PRINCIPAL_ID_FIELD_NAME;

@Override
public <K, V> Cache<K, V> getCache(String name) throws CacheException {
logger.debug("get cache, name={}", name);

Cache cache = caches.get(name);

if (cache == null) {
cache = new RedisCache<K, V>(redisManager, keyPrefix + name + ":", expire, principalIdFieldName);
caches.put(name, cache);
}
return cache;
}

public RedisManager getRedisManager() {
return redisManager;
}

public void setRedisManager(RedisManager redisManager) {
this.redisManager = redisManager;
}

public String getKeyPrefix() {
return keyPrefix;
}

public void setKeyPrefix(String keyPrefix) {
this.keyPrefix = keyPrefix;
}

public int getExpire() {
return expire;
}

public void setExpire(int expire) {
this.expire = expire;
}

public String getPrincipalIdFieldName() {
return principalIdFieldName;
}

public void setPrincipalIdFieldName(String principalIdFieldName) {
this.principalIdFieldName = principalIdFieldName;
}
}
创建RedisSessionDAO
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
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
public class RedisSessionDAO extends AbstractSessionDAO {

private static Logger logger = LoggerFactory.getLogger(RedisSessionDAO.class);

private static final String DEFAULT_SESSION_KEY_PREFIX = "shiro:session:";
private String keyPrefix = DEFAULT_SESSION_KEY_PREFIX;

private static final long DEFAULT_SESSION_IN_MEMORY_TIMEOUT = 1000L;
/**
* doReadSession be called about 10 times when login.
* Save Session in ThreadLocal to resolve this problem. sessionInMemoryTimeout is expiration of Session in ThreadLocal.
* The default value is 1000 milliseconds (1s).
* Most of time, you don't need to change it.
*/
private long sessionInMemoryTimeout = DEFAULT_SESSION_IN_MEMORY_TIMEOUT;

/**
* expire time in seconds
*/
private static final int DEFAULT_EXPIRE = -2;
private static final int NO_EXPIRE = -1;

/**
* Please make sure expire is longer than sesion.getTimeout()
*/
private int expire = DEFAULT_EXPIRE;

private static final int MILLISECONDS_IN_A_SECOND = 1000;

private RedisManager redisManager;
private static ThreadLocal sessionsInThread = new ThreadLocal();

@Override
public void update(Session session) throws UnknownSessionException {
//如果会话过期/停止 没必要再更新了
try {
if (session instanceof ValidatingSession && !((ValidatingSession) session).isValid()) {
return;
}

if (session instanceof ShiroSession) {
// 如果没有主要字段(除lastAccessTime以外其他字段)发生改变
ShiroSession ss = (ShiroSession) session;
if (!ss.isChanged()) {
return;
}
//如果没有返回 证明有调用 setAttribute往redis 放的时候永远设置为false
ss.setChanged(false);
}

this.saveSession(session);
} catch (Exception e) {
logger.warn("update Session is failed", e);
}
}

/**
* save session
* @param session
* @throws UnknownSessionException
*/
private void saveSession(Session session) throws UnknownSessionException {
if (session == null || session.getId() == null) {
logger.error("session or session id is null");
throw new UnknownSessionException("session or session id is null");
}
String key = getRedisSessionKey(session.getId());
if (expire == DEFAULT_EXPIRE) {
this.redisManager.set(key, session, (int) (session.getTimeout() / MILLISECONDS_IN_A_SECOND));
return;
}
if (expire != NO_EXPIRE && expire * MILLISECONDS_IN_A_SECOND < session.getTimeout()) {
logger.warn("Redis session expire time: "
+ (expire * MILLISECONDS_IN_A_SECOND)
+ " is less than Session timeout: "
+ session.getTimeout()
+ " . It may cause some problems.");
}
this.redisManager.set(key, session, expire);
}

@Override
public void delete(Session session) {
if (session == null || session.getId() == null) {
logger.error("session or session id is null");
return;
}
try {
redisManager.del(getRedisSessionKey(session.getId()));
} catch (Exception e) {
logger.error("delete session error. session id= {}",session.getId());
}
}

@Override
public Collection<Session> getActiveSessions() {
Set<Session> sessions = new HashSet<Session>();
try {
Set<String> keys = redisManager.scan(this.keyPrefix + "*");
if (keys != null && keys.size() > 0) {
for (String key:keys) {
Session s = (Session) redisManager.get(key);
sessions.add(s);
}
}
} catch (Exception e) {
logger.error("get active sessions error.");
}
return sessions;
}

public Long getActiveSessionsSize() {
Long size = 0L;
try {
size = redisManager.scanSize(this.keyPrefix + "*");
} catch (Exception e) {
logger.error("get active sessions error.");
}
return size;
}

@Override
protected Serializable doCreate(Session session) {
if (session == null) {
logger.error("session is null");
throw new UnknownSessionException("session is null");
}
Serializable sessionId = this.generateSessionId(session);
this.assignSessionId(session, sessionId);
this.saveSession(session);
return sessionId;
}

@Override
protected Session doReadSession(Serializable sessionId) {
if (sessionId == null) {
logger.warn("session id is null");
return null;
}
Session s = getSessionFromThreadLocal(sessionId);

if (s != null) {
return s;
}

logger.debug("read session from redis");
try {
s = (Session) redisManager.get(getRedisSessionKey(sessionId));
setSessionToThreadLocal(sessionId, s);
} catch (Exception e) {
logger.error("read session error. settionId= {}",sessionId);
}
return s;
}

private void setSessionToThreadLocal(Serializable sessionId, Session s) {
Map<Serializable, SessionInMemory> sessionMap = (Map<Serializable, SessionInMemory>) sessionsInThread.get();
if (sessionMap == null) {
sessionMap = new HashMap<Serializable, SessionInMemory>();
sessionsInThread.set(sessionMap);
}
SessionInMemory sessionInMemory = new SessionInMemory();
sessionInMemory.setCreateTime(new Date());
sessionInMemory.setSession(s);
sessionMap.put(sessionId, sessionInMemory);
}

private Session getSessionFromThreadLocal(Serializable sessionId) {
Session s = null;

if (sessionsInThread.get() == null) {
return null;
}

Map<Serializable, SessionInMemory> sessionMap = (Map<Serializable, SessionInMemory>) sessionsInThread.get();
SessionInMemory sessionInMemory = sessionMap.get(sessionId);
if (sessionInMemory == null) {
return null;
}
Date now = new Date();
long duration = now.getTime() - sessionInMemory.getCreateTime().getTime();
if (duration < sessionInMemoryTimeout) {
s = sessionInMemory.getSession();
logger.debug("read session from memory");
} else {
sessionMap.remove(sessionId);
}

return s;
}

private String getRedisSessionKey(Serializable sessionId) {
return this.keyPrefix + sessionId;
}

public RedisManager getRedisManager() {
return redisManager;
}

public void setRedisManager(RedisManager redisManager) {
this.redisManager = redisManager;
}

public String getKeyPrefix() {
return keyPrefix;
}

public void setKeyPrefix(String keyPrefix) {
this.keyPrefix = keyPrefix;
}

public long getSessionInMemoryTimeout() {
return sessionInMemoryTimeout;
}

public void setSessionInMemoryTimeout(long sessionInMemoryTimeout) {
this.sessionInMemoryTimeout = sessionInMemoryTimeout;
}

public int getExpire() {
return expire;
}

public void setExpire(int expire) {
this.expire = expire;
}
}

自定义异常类

PrincipalInstanceException
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class PrincipalInstanceException extends RuntimeException  {

private static final String MESSAGE = "We need a field to identify this Cache Object in Redis. "
+ "So you need to defined an id field which you can get unique id to identify this principal. "
+ "For example, if you use UserInfo as Principal class, the id field maybe userId, userName, email, etc. "
+ "For example, getUserId(), getUserName(), getEmail(), etc.\n"
+ "Default value is authCacheKey or id, that means your principal object has a method called \"getAuthCacheKey()\" or \"getId()\"";

public PrincipalInstanceException(Class clazz, String idMethodName) {
super(clazz + " must has getter for field: " + idMethodName + "\n" + MESSAGE);
}

public PrincipalInstanceException(Class clazz, String idMethodName, Exception e) {
super(clazz + " must has getter for field: " + idMethodName + "\n" + MESSAGE, e);
}
}
PrincipalIdNullException
1
2
3
4
5
6
7
8
public class PrincipalIdNullException extends RuntimeException  {

private static final String MESSAGE = "Principal Id shouldn't be null!";

public PrincipalIdNullException(Class clazz, String idMethodName) {
super(clazz + " id field: " + idMethodName + ", value is null\n" + MESSAGE);
}
}

重写ShiroRealm

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
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
public class ShiroRealm extends AuthorizingRealm {


private UserServiceAgent userServiceAgent = new UserServiceAgent();

private RoleServiceAgent roleServiceAgent = new RoleServiceAgent();

private PermissionServiceAgent permissionServiceAgent = new PermissionServiceAgent();

/**
* 验证用户身份
*
* @param authenticationToken
* @return
* @throws AuthenticationException
*/

@Override
protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authenticationToken) throws AuthenticationException {

//获取用户名密码 第一种方式
//String username = (String) authenticationToken.getPrincipal();
//String password = new String((char[]) authenticationToken.getCredentials());

//获取用户名 密码 第二种方式
UsernamePasswordToken usernamePasswordToken = (UsernamePasswordToken) authenticationToken;
String username = usernamePasswordToken.getUsername();
String password = new String(usernamePasswordToken.getPassword());

//从数据库查询用户信息
User user = userServiceAgent.findByUserName(username);

//可以在这里直接对用户名校验,或者调用 CredentialsMatcher 校验
if (user == null) {
throw new UnknownAccountException("用户名或密码错误!");
}

if ("1".equals(user.getState())) {
throw new LockedAccountException("账号已被锁定,请联系管理员!");
}

//调用 CredentialsMatcher 校验 还需要创建一个类 继承CredentialsMatcher 如果在上面校验了,这个就不需要了
//配置自定义权限登录器 参考博客:

SimpleAuthenticationInfo info = new SimpleAuthenticationInfo(user, user.getPassword(), getName());
return info;
}

/*
* 授权用户权限
* 授权的方法是在碰到<shiro:hasPermission name=''></shiro:hasPermission>标签的时候调用的
* 它会去检测shiro框架中的权限(这里的permissions)是否包含有该标签的name值,如果有,里面的内容显示
* 如果没有,里面的内容不予显示(这就完成了对于权限的认证.)
* <p>
* shiro的权限授权是通过继承AuthorizingRealm抽象类,重载doGetAuthorizationInfo();
* 当访问到页面的时候,链接配置了相应的权限或者shiro标签才会执行此方法否则不会执行
* 所以如果只是简单的身份认证没有权限的控制的话,那么这个方法可以不进行实现,直接返回null即可。
* <p>
* 在这个方法中主要是使用类:SimpleAuthorizationInfo 进行角色的添加和权限的添加。
* authorizationInfo.addRole(role.getRole()); authorizationInfo.addStringPermission(p.getPermission());
* <p>
* 当然也可以添加set集合:roles是从数据库查询的当前用户的角色,stringPermissions是从数据库查询的当前用户对应的权限
* authorizationInfo.setRoles(roles); authorizationInfo.setStringPermissions(stringPermissions);
* <p>
* 就是说如果在shiro配置文件中添加了filterChainDefinitionMap.put("/add", "perms[权限添加]");
* 就说明访问/add这个链接必须要有“权限添加”这个权限才可以访问
* <p>
* 如果在shiro配置文件中添加了filterChainDefinitionMap.put("/add", "roles[100002],perms[权限添加]");
* 就说明访问/add这个链接必须要有 "权限添加" 这个权限和具有 "100002" 这个角色才可以访问
*
* @param principalCollection
* @return
*/

@Override
protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principalCollection) {

//获取用户
User user = (User) SecurityUtils.getSubject().getPrincipal();

//获取用户角色
Set<Role> roles = roleServiceAgent.findRolesByUserId(user.getUid());
//添加角色
SimpleAuthorizationInfo authorizationInfo = new SimpleAuthorizationInfo();
for (Role role : roles) {
authorizationInfo.addRole(role.getRole());
}

//获取用户权限
Set<Permission> permissions = permissionServiceAgent.findPermissionsByRoleId(roles);
//添加权限
for (Permission permission : permissions) {
authorizationInfo.addStringPermission(permission.getPermission());
}

return authorizationInfo;
}

/**
* 写方法,
* 清除当前用户的的 授权缓存
*
* @param principals
*/

@Override

public void clearCachedAuthorizationInfo(PrincipalCollection principals) {
super.clearCachedAuthorizationInfo(principals);
}

/**
* 重写方法,
* 清除当前用户的 认证缓存
*
* @param principals
*/

@Override

public void clearCachedAuthenticationInfo(PrincipalCollection principals) {
super.clearCachedAuthenticationInfo(principals);
}

@Override
public void clearCache(PrincipalCollection principals) {
super.clearCache(principals);
}

/**
* 自定义方法:
* 清除所有 授权缓存
*/

public void clearAllCachedAuthorizationInfo() {
getAuthorizationCache().clear();
}

/**
* 自定义方法:
* 清除所有 认证缓存
*/
public void clearAllCachedAuthenticationInfo() {
getAuthenticationCache().clear();
}

/**
* 自定义方法:
* 清除所有的 认证缓存
* 和 授权缓存
*/

public void clearAllCache() {
clearAllCachedAuthenticationInfo();
clearAllCachedAuthorizationInfo();
}

}

ShiroConfig 配置

配置redisManagerAgent
1
2
3
4
@Bean("redisManagerAgent")
public RedisManager redisManager() {
return new RedisManagerAgent();
}
配置缓存管理器
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/**
* shiro缓存管理器;
* 需要添加到securityManager中
*
* @return
*/
@Bean
public RedisCacheManager cacheManager() {
RedisCacheManager redisCacheManager = new RedisCacheManager();
redisCacheManager.setRedisManager(redisManager());
//redis中针对不同用户缓存
redisCacheManager.setPrincipalIdFieldName("username");
//用户权限信息缓存时间
redisCacheManager.setExpire(200000);
return redisCacheManager;
}
配置密码比较器
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
/**
* 配置密码比较器
*
* @return
*/

@Bean("credentialsMatcher")
public RetryLimitHashedCredentialsMatcher retryLimitHashedCredentialsMatcher() {
RetryLimitHashedCredentialsMatcher retryLimitHashedCredentialsMatcher = new RetryLimitHashedCredentialsMatcher();
retryLimitHashedCredentialsMatcher.setRedisManager(redisManager());

//如果密码加密,可以打开下面配置
//加密算法的名称
//retryLimitHashedCredentialsMatcher.setHashAlgorithmName("MD5");
//配置加密的次数
//retryLimitHashedCredentialsMatcher.setHashIterations(1024);
//是否存储为16进制
//retryLimitHashedCredentialsMatcher.setStoredCredentialsHexEncoded(true);

return retryLimitHashedCredentialsMatcher;
}

并发登录控制
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
/**
* 并发登录控制
*
* @return
*/

@Bean
public KickoutSessionControlFilter kickoutSessionControlFilter() {
KickoutSessionControlFilter kickoutSessionControlFilter = new KickoutSessionControlFilter(); //用于根据会话ID,获取会话进行踢出操作的;
kickoutSessionControlFilter.setSessionManager(sessionManager());
//使用cacheManager获取相应的cache来缓存用户登录的会话;用于保存用户—会话之间的关系的;
kickoutSessionControlFilter.setRedisManager(redisManager());
//是否踢出后来登录的,默认是false;即后者登录的用户踢出前者登录的用户;
kickoutSessionControlFilter.setKickoutAfter(false);
//同一个用户最大的会话数,默认1;比如2的意思是同一个用户允许最多同时两个人登录;
kickoutSessionControlFilter.setMaxSession(1);
//被踢出后重定向到的地址;
kickoutSessionControlFilter.setKickoutUrl("/login?kickout=1");
return kickoutSessionControlFilter;
}
配置sessionDAO
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/**
* SessionDAO的作用是为Session提供CRUD并进行持久化的一个shiro组件
* MemorySessionDAO 直接在内存中进行会话维护
* EnterpriseCacheSessionDAO 提供了缓存功能的会话维护,默认情况下使用MapCache实现,内部使用ConcurrentHashMap保存缓存的会话。
*
* @return
*/

@Bean
public SessionDAO sessionDAO() {
RedisSessionDAO redisSessionDAO = new RedisSessionDAO();
redisSessionDAO.setRedisManager(redisManager());
//session在redis中的保存时间,最好大于session会话超时时间
redisSessionDAO.setExpire(12000);
return redisSessionDAO;
}
配置sessionFactory
1
2
3
4
5
@Bean("sessionFactory")
public ShiroSessionFactory sessionFactory() {
ShiroSessionFactory sessionFactory = new ShiroSessionFactory();
return sessionFactory;
}
配置cacheManager
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/**
* shiro缓存管理器;
* 需要添加到securityManager中
*
* @return
*/
@Bean
public RedisCacheManager cacheManager() {
RedisCacheManager redisCacheManager = new RedisCacheManager();
redisCacheManager.setRedisManager(redisManager());
//redis中针对不同用户缓存
redisCacheManager.setPrincipalIdFieldName("username");
//用户权限信息缓存时间
redisCacheManager.setExpire(200000);
return redisCacheManager;
}
配置Session监听器
1
2
3
4
5
6
7
8
9
10
11
/**
* 配置session监听
*
* @return
*/

@Bean("sessionListener")
public ShiroSessionListener sessionListener() {
ShiroSessionListener sessionListener = new ShiroSessionListener();
return sessionListener;
}
配置会话管理器
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
/**
* 配置会话管理器,设定会话超时及保存
*
* @return
*/
@Bean("sessionManager")
public SessionManager sessionManager() {
ShiroSessionManager sessionManager = new ShiroSessionManager();
Collection<SessionListener> listeners = new ArrayList<SessionListener>();
//配置监听
listeners.add(sessionListener());
sessionManager.setSessionListeners(listeners);
sessionManager.setSessionIdCookie(sessionIdCookie());
sessionManager.setSessionDAO(sessionDAO());
sessionManager.setCacheManager(cacheManager());
sessionManager.setSessionFactory(sessionFactory());

//全局会话超时时间(单位毫秒),默认30分钟 暂时设置为10秒钟 用来测试
sessionManager.setGlobalSessionTimeout(1800000);
//是否开启删除无效的session对象 默认为true
sessionManager.setDeleteInvalidSessions(true);
//是否开启定时调度器进行检测过期session 默认为true
sessionManager.setSessionValidationSchedulerEnabled(true);
//设置session失效的扫描时间, 清理用户直接关闭浏览器造成的孤立会话 默认为 1个小时
//设置该属性 就不需要设置 ExecutorServiceSessionValidationScheduler 底层也是默认自动调用ExecutorServiceSessionValidationScheduler
//暂时设置为 5秒 用来测试
sessionManager.setSessionValidationInterval(3600000);
//取消url 后面的 JSESSIONID
sessionManager.setSessionIdUrlRewritingEnabled(false);
return sessionManager;

}

安全事务管理器
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
/**
* 配置核心安全事务管理器
*
* @return
*/

@Bean(name = "securityManager")
public SecurityManager securityManager() {
DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager();
//设置自定义realm.
securityManager.setRealm(shiroRealm());
//配置记住我
securityManager.setRememberMeManager(rememberMeManager());
//配置redis缓存
securityManager.setCacheManager(cacheManager());
//配置自定义session管理,使用redis
securityManager.setSessionManager(sessionManager());
return securityManager;
}

评论