上文授权篇中,我们已经完成了对token的颁发及清除,上述操作实际上并不需要真正与shiro进行整合。在这一篇章中我将会说明关于整合shiro后如何进行token的鉴权,同时这也将是实现无状态登录鉴权的最后重头戏。

一、Maven配置

主要配置如下,具体引用请参照源码:

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
<!-- jwt -->
<dependency>
<groupId>com.auth0</groupId>
<artifactId>java-jwt</artifactId>
<version>${java-jwt.version}</version>
</dependency>

<!-- shiro -->
<dependency>
<groupId>org.apache.shiro</groupId>
<artifactId>shiro-spring</artifactId>
<version>${shiro-spring.version}</version>
</dependency>

<!-- mysql连接器 -->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
</dependency>

<!-- druid连接池 -->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>druid-spring-boot-starter</artifactId>
<version>${druid.version}</version>
</dependency>

<!-- myBatis plus starter -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>${mybatis-plus-boot.version}</version>
</dependency>

<!-- myBatis plus -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus</artifactId>
<version>${mybatis-plus.version}</version>
</dependency>

二、Application配置

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
server:
port: 8002

spring:
application:
name: springboot-shiro-jwt-web
# profiles: springboot-shiro-jwt-web

## 数据库配置 - start
datasource:
url: jdbc:mysql://localhost:3306/springboot-master?useSSL=false&allowMultiQueries=true&useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai&tinyInt1isBit=false
username: root
password: root
type: com.alibaba.druid.pool.DruidDataSource
# driver-class-name: com.mysql.cj.jdbc.Driver
# 连接池配置
druid:
# 初始化大小,最小,最大
initial-size: 5
min-idle: 5
max-active: 20
# 配置获取连接等待超时的时间
max-wait: 60000
# 配置间隔多久才进行一次检测,检测需要关闭的空闲连接,单位是毫秒
time-between-eviction-runs-millis: 60000
# 配置一个连接在池中最小生存的时间,单位是毫秒
min-evictable-idle-time-millis: 300000
validation-query: SELECT 1 FROM DUAL
test-while-idle: true
test-on-borrow: false
test-on-return: false
# 打开PSCache,并且指定每个连接上PSCache的大小
pool-prepared-statements: true
max-pool-prepared-statement-per-connection-size: 20
# 配置监控统计拦截的filters,去掉后监控界面sql无法统计,'wall'用于防火墙
filters: stat,wall,log4j2
use-global-data-source-stat: true
# 通过connectProperties属性来打开mergeSql功能;慢SQL记录
connect-properties: druid.stat.mergeSql=true;druid.stat.slowSqlMillis=5000
# 配置监控服务器
stat-view-servlet:
url-pattern: /druid/*
login-username: admin
login-password: 123456
reset-enable: false
# 添加IP白名单
#allow:
# 添加IP黑名单,当白名单和黑名单重复时,黑名单优先级更高
#deny:
web-stat-filter:
# 添加过滤规则
url-pattern: /*
# 忽略过滤格式
exclusions: "*.js,*.gif,*.jpg,*.jpeg,*.png,*.css,*.ico,/druid/*"
## 数据库配置 - end


## Redis配置 - start
redis:
# Redis数据库索引(默认为0)
database: 1
# Redis服务器地址
host: 127.0.0.1
# Redis服务器连接端口
port: 6379
# Redis服务器连接密码(默认为空)
# password: "doufuplus"
# 连接超时时间(毫秒)
timeout: 5000
jedis:
pool:
# 连接池最大连接数(使用负值表示没有限制)
max-active: 8
# 连接池最大阻塞等待时间(使用负值表示没有限制)
max-wait: -1
# 连接池中的最大空闲连接
max-idle: 8
# 连接池中的最小空闲连接
min-idle: 0
## Redis配置 - end


## mybatis配置 - start
mybatis-plus:
# mapper.xml扫描
mapper-locations: classpath*:/mapper/*.xml
# 实体扫描,多个package用逗号或者分号分隔
type-aliases-package: com.doufuplus.boot.shiro.entity
global-config:
db-config:
# 主键类型
id-type: UUID
# 字段策略
# field-strategy: DEFAULT
# 数据库大写下划线转换
capital-mode: true
# 序列接口实现类配置
# key-generator: com.baomidou.mybatisplus.core.incrementer
# 逻辑删除配置
logic-delete-value: 1
logic-not-delete-value: 0

configuration:
# 开启自动驼峰命名规则
map-underscore-to-camel-case: true
cache-enabled: false
call-setters-on-nulls: true
# 打印SQL语句
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
## mybatis配置 - end


## product配置 - start
info:
app.name: springboot-shiro-jwt-web
company.name: doufuplus
build.artifactId: $project.artifactId$
build.modelVersion: $project.modelVersion$
## product配置 - end


## 日志配置 - start
logging:
level:
com.nfgj.medical.service: DEBUG
## 日志配置 - end


## 其它配置 - start
config:
# JWT认证加密私钥(Base64加密)
encrypt-jwtKey: U0JBUElOENhspJrzkyNjQ1NA
# AccessToken过期时间(秒)
accessToken-expireTime: 600
# RefreshToken过期时间(秒)
refreshToken-expireTime: 604800
# Shiro缓存过期时间(秒)(一般设置与AccessToken过期时间一致) 此处CustomCache读取失败,待解决
shiro-cache-expireTime: 600
## 其它配置 - end

三、重写过滤器

总览篇中我已提到,鉴权流程主要是重写shiro的入口过滤器BasicHttpAuthenticationFilter。重写主要是做三件事情:

  1. 判断请求是否需要进行登录认证授权(可在此写拦截白名单),如果需要则该请求就必须在Header中添加Authorization字段存放AccessToken,无需授权即游客直接访问(有权限管控的话,以游客访问就会被拦截)。
  2. 调用getSubject(request, response).login(token),将AccessToken提交给shiro中的UserRealm进行认证。
  3. AccessToken刷新:判断RefreshToken是否过期,未过期就返回新的AccessToken及RefreshToken并让请求继续正常访问。
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
/**
* JWT过滤器
* 转载请注明出处,更多技术文章欢迎大家访问我的个人博客站点:https://www.doufuplus.com
*
* @author 丶doufu
* @date 2019/08/03
*/
public class JwtFilter extends BasicHttpAuthenticationFilter {

@Value("${config.refreshToken-expireTime}")
private String refreshTokenExpireTime;

@Autowired
private RedisClient redis;

/**
* 这里我们详细说明下为什么最终返回的都是true,即允许访问 例如我们提供一个地址 GET /article 登入用户和游客看到的内容是不同的
* 如果在这里返回了false,请求会被直接拦截,用户看不到任何东西 所以我们在这里返回true,Controller中可以通过
* subject.isAuthenticated() 来判断用户是否登入
* 如果有些资源只有登入用户才能访问,我们只需要在方法上面加上 @RequiresAuthentication 注解即可
* 但是这样做有一个缺点,就是不能够对GET,POST等请求进行分别过滤鉴权(因为我们重写了官方的方法),但实际上对应用影响不大
*/
@Override
protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) {
// 判断用户是否想要登入
if (this.isLoginAttempt(request, response)) {
try {
// 进行Shiro的登录UserRealm
this.executeLogin(request, response);
} catch (Exception e) {
// 认证出现异常,传递错误信息msg
String msg = e.getMessage();
// 获取应用异常(该Cause是导致抛出此throwable(异常)的throwable(异常))
Throwable throwable = e.getCause();
if (throwable != null && throwable instanceof SignatureVerificationException) {
// 该异常为JWT的AccessToken认证失败(Token或者密钥不正确)
msg = "token或者密钥不正确(" + throwable.getMessage() + ")";
} else if (throwable != null && throwable instanceof TokenExpiredException) {
// 该异常为JWT的AccessToken已过期,判断RefreshToken未过期就进行AccessToken刷新
if (this.refreshToken(request, response)) {
return true;
} else {
msg = "token已过期(" + throwable.getMessage() + ")";
}
} else {
// 应用异常不为空
if (throwable != null) {
// 获取应用异常msg
msg = throwable.getMessage();
}
}
/**
* 错误两种处理方式 1. 将非法请求转发到/401的Controller处理,抛出自定义无权访问异常被全局捕捉再返回Response信息 2.
* 无需转发,直接返回Response信息 一般使用第二种(更方便)
*/
// 直接返回Response信息
this.response401(request, response, msg);
return false;
}
}
return true;
}

/**
* 这里我们详细说明下为什么重写 可以对比父类方法,只是将executeLogin方法调用去除了
* 如果没有去除将会循环调用doGetAuthenticationInfo方法
*/
@Override
protected boolean onAccessDenied(ServletRequest request, ServletResponse response) throws Exception {
this.sendChallenge(request, response);
return false;
}

/**
* 检测Header里面是否包含Authorization字段,有就进行Token登录认证授权
*/
@Override
protected boolean isLoginAttempt(ServletRequest request, ServletResponse response) {
// 拿到当前Header中Authorization的AccessToken(Shiro中getAuthzHeader方法已经实现)
// String requestURI = ((HttpServletRequest) request).getRequestURI();
// String token = this.getAuthzHeader(request);
// return token != null;
// 默认全部都需校验
return true;
}

/**
* 进行AccessToken登录认证授权
*/
@Override
protected boolean executeLogin(ServletRequest request, ServletResponse response) throws Exception {
// 拿到当前Header中Authorization的AccessToken(Shiro中getAuthzHeader方法已经实现)
JwtToken token = new JwtToken(this.getAuthzHeader(request));
// 提交给UserRealm进行认证,如果错误他会抛出异常并被捕获
this.getSubject(request, response).login(token);
// 如果没有抛出异常则代表登入成功,返回true
return true;
}

/**
* 刷新AccessToken,进行判断RefreshToken是否过期,未过期就返回新的AccessToken且继续正常访问
*/
private boolean refreshToken(ServletRequest request, ServletResponse response) {
// 拿到当前Header中Authorization的AccessToken(Shiro中getAuthzHeader方法已经实现)
String token = this.getAuthzHeader(request);
// 获取当前Token的帐号信息
String account = JwtUtil.getClaim(token, JwtConstant.ACCOUNT);
// 判断Redis中RefreshToken是否存在
if (redis.hasKey(RedisConstant.PREFIX_SHIRO_REFRESH_TOKEN + account)) {
// Redis中RefreshToken还存在,获取RefreshToken的时间戳
String currentTimeMillisRedis = redis.get(RedisConstant.PREFIX_SHIRO_REFRESH_TOKEN + account).toString();
// 获取当前AccessToken中的时间戳,与RefreshToken的时间戳对比,如果当前时间戳一致,进行AccessToken刷新
if (JwtUtil.getClaim(token, JwtConstant.CURRENT_TIME_MILLIS).equals(currentTimeMillisRedis)) {
// 获取当前最新时间戳
String currentTimeMillis = String.valueOf(System.currentTimeMillis());
// 读取配置文件,获取refreshTokenExpireTime属性
// PropertiesUtil.readProperties("config.properties");
// String refreshTokenExpireTime =
// PropertiesUtil.getProperty("refreshTokenExpireTime");
// 设置RefreshToken中的时间戳为当前最新时间戳,且刷新过期时间重新为30分钟过期(配置文件可配置refreshTokenExpireTime属性)
redis.set(RedisConstant.PREFIX_SHIRO_REFRESH_TOKEN + account, currentTimeMillis,
Integer.parseInt(refreshTokenExpireTime));
// 刷新AccessToken,设置时间戳为当前最新时间戳
token = JwtUtil.sign(account, currentTimeMillis);
// 将新刷新的AccessToken再次进行Shiro的登录
JwtToken jwtToken = new JwtToken(token);
// 提交给UserRealm进行认证,如果错误他会抛出异常并被捕获,如果没有抛出异常则代表登入成功,返回true
this.getSubject(request, response).login(jwtToken);
// 最后将刷新的AccessToken存放在Response的Header中的Authorization字段返回
HttpServletResponse httpServletResponse = (HttpServletResponse) response;
httpServletResponse.setHeader("Authorization", token);
httpServletResponse.setHeader("Access-Control-Expose-Headers", "Authorization");
return true;
}
}
return false;
}

/**
* 无需转发,直接返回Response信息
*/
private void response401(ServletRequest req, ServletResponse resp, String msg) {
HttpServletResponse httpServletResponse = (HttpServletResponse) resp;
httpServletResponse.setStatus(HttpStatus.UNAUTHORIZED.value());
httpServletResponse.setCharacterEncoding("UTF-8");
httpServletResponse.setContentType("application/json; charset=utf-8");
PrintWriter out = null;
try {
out = httpServletResponse.getWriter();
String data = JsonConvertUtil.objectToJson(new Result(ResultCode.NOT_LOGIN, msg));
out.append(data);
} catch (IOException e) {
throw new CustomException("直接返回Response信息出现IOException异常:" + e.getMessage());
} finally {
if (out != null) {
out.close();
}
}
}

/**
* 对跨域提供支持
*/
@Override
protected boolean preHandle(ServletRequest request, ServletResponse response) throws Exception {
HttpServletRequest httpServletRequest = (HttpServletRequest) request;
HttpServletResponse httpServletResponse = (HttpServletResponse) response;
httpServletResponse.setHeader("Access-control-Allow-Origin", httpServletRequest.getHeader("Origin"));
httpServletResponse.setHeader("Access-Control-Allow-Methods", "GET,POST,OPTIONS,PUT,DELETE");
httpServletResponse.setHeader("Access-Control-Allow-Headers",
httpServletRequest.getHeader("Access-Control-Request-Headers"));
// 跨域时会首先发送一个OPTIONS请求,这里我们给OPTIONS请求直接返回正常状态
if (httpServletRequest.getMethod().equals(RequestMethod.OPTIONS.name())) {
httpServletResponse.setStatus(HttpStatus.OK.value());
return false;
}
return super.preHandle(request, response);
}
}

四、自定义Realm

与常规shiro一致,我们在Realm中做相关的身份、权限等认证授权

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
/**
* 自定义Realm
* 转载请注明出处,更多技术文章欢迎大家访问我的个人博客站点:https://www.doufuplus.com
*
* @author 丶doufu
* @date 2019/08/03
*/
@Service
public class UserRealm extends AuthorizingRealm {

@Autowired
private RedisClient redis;

@Autowired
private UserMapper userMapper;

/**
* 大坑,必须重写此方法,不然Shiro会报错
*/
@Override
public boolean supports(AuthenticationToken token) {
return token instanceof JwtToken;
}

/**
* 只有当需要检测用户权限的时候才会调用此方法,例如checkRole,checkPermission之类的
*/
@Override
protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) {
SimpleAuthorizationInfo simpleAuthorizationInfo = new SimpleAuthorizationInfo();
/*
// 返回当前用户所拥有的角色、权限等信息,根据自身项目编码即可
String account = JwtUtil.getClaim(principals.toString(), JwtConstant.ACCOUNT);
// 查询用户角色
List<Role> roles = roleMapper.findByAccount(account);
for (int i = 0, roleLen = roles.size(); i < roleLen; i++) {
Role role = roles.get(i);
// 添加角色
simpleAuthorizationInfo.addRole(role.getName());
// 根据用户角色查询权限
List<Permission> permissions = permissionMapper.findByRoleId(role.getId());
for (int j = 0, perLen = permissions.size(); j < perLen; j++) {
Permission permission = permissions.get(j);
// 添加权限
simpleAuthorizationInfo.addStringPermission(permission.getSn());
}
}
*/
return simpleAuthorizationInfo;
}

/**
* 默认使用此方法进行用户名正确与否验证,错误抛出异常即可。
*/
@Override
protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken auth) throws AuthenticationException {
String token = (String) auth.getCredentials();
if (StringUtils.isBlank(token)) {
throw new AuthenticationException("token cannot be empty.");
}

// 解密获得account,用于和数据库进行对比
String account = JwtUtil.getClaim(token, JwtConstant.ACCOUNT);
// 帐号为空
if (StringUtils.isBlank(account)) {
throw new AuthenticationException("token中帐号为空(The account in Token is empty.)");
}
// 查询用户是否存在
User user = userMapper.findByAccount(account);
if (user == null) {
throw new AuthenticationException("该帐号不存在(The account does not exist.)");
}
// 开始认证,要AccessToken认证通过,且Redis中存在RefreshToken,且两个Token时间戳一致
if (JwtUtil.verify(token) && redis.hasKey(RedisConstant.PREFIX_SHIRO_REFRESH_TOKEN + account)) {
// 获取RefreshToken的时间戳
String currentTimeMillisRedis = redis.get(RedisConstant.PREFIX_SHIRO_REFRESH_TOKEN + account).toString();
// 获取AccessToken时间戳,与RefreshToken的时间戳对比
if (JwtUtil.getClaim(token, JwtConstant.CURRENT_TIME_MILLIS).equals(currentTimeMillisRedis)) {
return new SimpleAuthenticationInfo(token, token, "userRealm");
}
}
throw new AuthenticationException("token expired or incorrect.");
}
}

五、Shiro配置

这里注意下关于JwtFilter的配置,由于spring boot中filter加载顺序原因,JwtFilter的Bean注入应放置于shiroFilter之后,否则将报如下异常:

1
2
No SecurityManager accessible to the calling code, either bound to the org.apache.shiro.util.
ThreadContext or as a vm static singleton. This is an invalid application configuration.

代码如下:

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
/**
* Shiro配置
* 转载请注明出处,更多技术文章欢迎大家访问我的个人博客站点:https://www.doufuplus.com
*
* @author 丶doufu
* @date 2019/08/03
*/
@Configuration
public class ShiroConfig {

/**
* 配置使用自定义Realm,关闭Shiro自带的session 详情见文档
* http://shiro.apache.org/session-management.html#SessionManagement-StatelessApplications%28Sessionless%29
*/
@Bean("securityManager")
public DefaultWebSecurityManager getManager(UserRealm userRealm, RedisTemplate<String, Object> template) {
DefaultWebSecurityManager manager = new DefaultWebSecurityManager();
// 使用自定义Realm
manager.setRealm(userRealm);
// 关闭Shiro自带的session
DefaultSubjectDAO subjectDAO = new DefaultSubjectDAO();
DefaultSessionStorageEvaluator defaultSessionStorageEvaluator = new DefaultSessionStorageEvaluator();
defaultSessionStorageEvaluator.setSessionStorageEnabled(false);
subjectDAO.setSessionStorageEvaluator(defaultSessionStorageEvaluator);
manager.setSubjectDAO(subjectDAO);
// 设置自定义Cache缓存
manager.setCacheManager(new CustomCacheManager(template));
return manager;
}

/**
* 生成一个ShiroRedisCacheManager
**/
private CustomCacheManager cacheManager(RedisTemplate template) {
return new CustomCacheManager(template);
}

/**
* 添加自己的过滤器,自定义url规则 详情见文档 http://shiro.apache.org/web.html#urls-
*/
@Bean("shiroFilter")
public ShiroFilterFactoryBean factory(DefaultWebSecurityManager securityManager) {
ShiroFilterFactoryBean factoryBean = new ShiroFilterFactoryBean();
// 添加自己的过滤器取名为jwt
Map<String, Filter> filterMap = new HashMap<>(16);
filterMap.put("jwtFilter", jwtFilterBean());
factoryBean.setFilters(filterMap);
factoryBean.setSecurityManager(securityManager);
// 自定义url规则
Map<String, String> filterRuleMap = new HashMap<>(16);
// 所有请求通过我们自己的JWTFilter
filterRuleMap.put("/**", "jwtFilter");
factoryBean.setFilterChainDefinitionMap(filterRuleMap);
return factoryBean;
}

/**
* <pre>
* 注入bean,此处应注意:
*
* (1)代码顺序,应放置于shiroFilter后面,否则报错:
* No SecurityManager accessible to the calling code, either bound to the org.apache.shiro.util.
* ThreadContext or as a vm static singleton. This is an invalid application configuration.
*
* (2)如不在此注册,在filter中将无法正常注入bean
* </pre>
*/
@Bean("jwtFilter")
public JwtFilter jwtFilterBean() {
return new JwtFilter();
}

/**
* 下面的代码是添加注解支持
*/
@Bean
@DependsOn("lifecycleBeanPostProcessor")
public DefaultAdvisorAutoProxyCreator defaultAdvisorAutoProxyCreator() {
DefaultAdvisorAutoProxyCreator defaultAdvisorAutoProxyCreator = new DefaultAdvisorAutoProxyCreator();
// 强制使用cglib,防止重复代理和可能引起代理出错的问题,https://zhuanlan.zhihu.com/p/29161098
defaultAdvisorAutoProxyCreator.setProxyTargetClass(true);
return defaultAdvisorAutoProxyCreator;
}

@Bean
public LifecycleBeanPostProcessor lifecycleBeanPostProcessor() {
return new LifecycleBeanPostProcessor();
}

@Bean
public AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor(
DefaultWebSecurityManager securityManager) {
AuthorizationAttributeSourceAdvisor advisor = new AuthorizationAttributeSourceAdvisor();
advisor.setSecurityManager(securityManager);
return advisor;
}
}

六、重写Shiro缓存

6-1. 重写Shiro Cache为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
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
/**
* 重写Shiro的Cache保存读取
* 转载请注明出处,更多技术文章欢迎大家访问我的个人博客站点:https://www.doufuplus.com
*
* @author 丶doufu
* @date 2019/08/03
*/
public class CustomCache<K, V> implements Cache<K, V> {

// TODO redis @Autowired注入失败,因此改为下面采用传参形式
// @Autowired
// private RedisClient redis = new RedisClient();

// TODO @Value注入失败 @Value("${config.shiro-cache-expireTime}")
private String shiroCacheExpireTime = "600";

private RedisTemplate<String, Object> redisTemplate;

public CustomCache(RedisTemplate redisTemplate) {
// 使用StringRedisSerializer做序列化
// redisTemplate.setValueSerializer(new StringRedisSerializer());
this.redisTemplate = redisTemplate;
}

/**
* 缓存的key名称获取为shiro:cache:account
*
* @param key
* @return java.lang.String
* @author Wang926454
* @date 2018/9/4 18:33
*/
private String getKey(Object key) {
return RedisConstant.PREFIX_SHIRO_CACHE + JwtUtil.getClaim(key.toString(), JwtConstant.ACCOUNT);
}

/**
* 获取缓存
*/
@Override
public Object get(Object key) throws CacheException {
return redisTemplate.opsForValue().get(this.getKey(key));
}

/**
* 保存缓存
*/
@Override
public Object put(Object key, Object value) throws CacheException {
// 读取配置文件,获取Redis的Shiro缓存过期时间
// PropertiesUtil.readProperties("config.properties");
// String shiroCacheExpireTime =
// PropertiesUtil.getProperty("shiroCacheExpireTime");
// 设置Redis的Shiro缓存
try {
redisTemplate.opsForValue().set(this.getKey(key), value, Integer.parseInt(shiroCacheExpireTime), TimeUnit.SECONDS);
return true;
} catch (Exception e) {
e.printStackTrace();
return false;
}
}

/**
* 移除缓存
*/
@Override
public Object remove(Object key) throws CacheException {
redisTemplate.delete(this.getKey(key));
return null;
}

/**
* 清空所有缓存
*/
@Override
public void clear() throws CacheException {
// TODO Auto-generated method stub

}

/**
* 缓存的个数
*/
@Override
public Set<K> keys() {
// TODO Auto-generated method stub
return null;
}

/**
* 获取所有的key
*/
@Override
public int size() {
// TODO Auto-generated method stub
return 0;
}

/**
* 获取所有的value
*/
@Override
public Collection<V> values() {
// TODO Auto-generated method stub
return null;
}

/*
* @Override public void clear() throws CacheException {
* redis.getJedis().flushDB(); }
*/

/*
* @Override public int size() { Long size = JedisUtil.getJedis().dbSize();
* return size.intValue(); }
*/

/*
* @Override public Set keys() { Set<byte[]> keys =
* JedisUtil.getJedis().keys(new String("*").getBytes()); Set<Object> set = new
* HashSet<Object>(); for (byte[] bs : keys) {
* set.add(SerializableUtil.unserializable(bs)); } return set; }
*/

/*
* @Override public Collection values() { Set keys = this.keys(); List<Object>
* values = new ArrayList<Object>(); for (Object key : keys) {
* values.add(JedisUtil.getObject(this.getKey(key))); } return values; }
*/
}

6-2. 重写Shiro缓存管理器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
/**
* 重写Shiro缓存管理器
* 转载请注明出处,更多技术文章欢迎大家访问我的个人博客站点:https://www.doufuplus.com
*
* @author 丶doufu
* @date 2019/08/03
*/
public class CustomCacheManager implements CacheManager {

private RedisTemplate<String, Object> redisTemplate;

public CustomCacheManager(RedisTemplate redisTemplate) {
this.redisTemplate = redisTemplate;
}

@Override
public <K, V> Cache<K, V> getCache(String s) throws CacheException {
return new CustomCache<K, V>(redisTemplate);
}
}

七、自定义异常

为方便返回统一Json提示,我们就需要对shiro的异常信息进行重写,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
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
/**
* 异常控制处理器
* 转载请注明出处,更多技术文章欢迎大家访问我的个人博客站点:https://www.doufuplus.com
*
* @author 丶doufu
* @date 2019/08/03
*/
@RestControllerAdvice
public class ExceptionAdvice {

/**
* 捕捉所有Shiro异常
*/
@ResponseStatus(HttpStatus.UNAUTHORIZED)
@ExceptionHandler(ShiroException.class)
public Result handle401(ShiroException e) {
return new Result(ResultCode.UNLAWFUL, "无权访问(Unauthorized):" + e.getMessage());
}

/**
* 单独捕捉Shiro(UnauthorizedException)异常 该异常为访问有权限管控的请求而该用户没有所需权限所抛出的异常
*/
@ResponseStatus(HttpStatus.UNAUTHORIZED)
@ExceptionHandler(UnauthorizedException.class)
public Result handle401(UnauthorizedException e) {
Result result = new Result();
return new Result(ResultCode.UNLAWFUL, "无权访问(Unauthorized):当前Subject没有此请求所需权限(" + e.getMessage() + ")");
}

/**
* 单独捕捉Shiro(UnauthenticatedException)异常
* 该异常为以游客身份访问有权限管控的请求无法对匿名主体进行授权,而授权失败所抛出的异常
*/
@ResponseStatus(HttpStatus.UNAUTHORIZED)
@ExceptionHandler(UnauthenticatedException.class)
public Result handle401(UnauthenticatedException e) {
return new Result(ResultCode.UNLAWFUL, "无权访问(Unauthorized):当前Subject是匿名Subject,请先登录(This subject is anonymous.)");
}

/**
* 捕捉校验异常(BindException)
*/
@ResponseStatus(HttpStatus.BAD_REQUEST)
@ExceptionHandler(BindException.class)
public Result validException(BindException e) {
List<FieldError> fieldErrors = e.getBindingResult().getFieldErrors();
Map<String, Object> error = this.getValidError(fieldErrors);
return new Result(ResultCode.ERROR, error.get("errorMsg").toString(), error.get("errorList"));
}

/**
* 捕捉校验异常(MethodArgumentNotValidException)
*/
@ResponseStatus(HttpStatus.BAD_REQUEST)
@ExceptionHandler(MethodArgumentNotValidException.class)
public Result validException(MethodArgumentNotValidException e) {
List<FieldError> fieldErrors = e.getBindingResult().getFieldErrors();
Map<String, Object> error = this.getValidError(fieldErrors);
return new Result(ResultCode.ERROR, error.get("errorMsg").toString(), error.get("errorList"));
}

/**
* 捕捉404异常
*/
@ResponseStatus(HttpStatus.NOT_FOUND)
@ExceptionHandler(NoHandlerFoundException.class)
public Result handle(NoHandlerFoundException e) {
return new Result(ResultCode.NOT_FOUND, e.getMessage());
}

/**
* 捕捉其他所有异常
*/
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
@ExceptionHandler(Exception.class)
public Result globalException(HttpServletRequest request, Throwable ex) {
return new Result(ResultCode.ERROR, ex.toString() + ": " + ex.getMessage());
}

/**
* 捕捉其他所有自定义异常
*/
@ResponseStatus(HttpStatus.BAD_REQUEST)
@ExceptionHandler(CustomException.class)
public Result handle(CustomException e) {
return new Result(ResultCode.ERROR, e.getMessage());
}

/**
* 获取状态码
*/
private HttpStatus getStatus(HttpServletRequest request) {
Integer statusCode = (Integer) request.getAttribute("javax.servlet.error.status_code");
if (statusCode == null) {
return HttpStatus.INTERNAL_SERVER_ERROR;
}
return HttpStatus.valueOf(statusCode);
}

/**
* 获取校验错误信息
*/
private Map<String, Object> getValidError(List<FieldError> fieldErrors) {
Map<String, Object> map = new HashMap<String, Object>(16);
List<String> errorList = new ArrayList<String>();
StringBuffer errorMsg = new StringBuffer("校验异常(ValidException):");
for (FieldError error : fieldErrors) {
errorList.add(error.getField() + "-" + error.getDefaultMessage());
errorMsg.append(error.getField() + "-" + error.getDefaultMessage() + ".");
}
map.put("errorList", errorList);
map.put("errorMsg", errorMsg);
return map;
}
}

八、获取当前登录用户

由于在上面的JwtFilter中我们已经把token提交给了shiro,因此直接从Subject中获取即可:

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
/**
* 获取当前登录用户
* 转载请注明出处,更多技术文章欢迎大家访问我的个人博客站点:https://www.doufuplus.com
*
* @author 丶doufu
* @date 2019/8/10
*/
@RequestMapping("/current")
public Result current() {
try {
User user = new User();
Subject subject = SecurityUtils.getSubject();
if (subject != null) {
String token = (String) subject.getPrincipal();
if (StringUtils.isNotBlank(token)) {
String account = JwtUtil.getClaim(token, JwtConstant.ACCOUNT);
if (StringUtils.isNotBlank(account)) {
user = testService.findUserByAccount(account);
}
}
}
return new Result(ResultCode.SUCCESS, "success.", user);
} catch (Exception e) {
e.printStackTrace();
return new Result(ResultCode.ERROR, e.getMessage());
}
}

调用如下:
当前登录用户

九、效果演示

主要的代码基本到此就已经结束,接下来给大家看看最后的集成效果。

  1. 首先我们正常编写一个接口,代码如下:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    /**
    * test
    * 转载请注明出处,更多技术文章欢迎大家访问我的个人博客站点:https://www.doufuplus.com
    *
    * @author 丶doufu
    * @date 2019/8/10
    */
    @RequestMapping("/test")
    public Result test() {
    return new Result(ResultCode.SUCCESS, "Hello SHIRO JWT!");
    }
  2. 使用postman直接接口访问,提示token cannot be empty.
    直接访问

  1. 携带上文登录接口中生成的token访问:
    携带token
  1. 由于我们是使用doufuplus这个账号登录生成的token,修改掉账号如下:
    数据库账号

  继续访问效果如下:
账号访问测试

至于其它的异常情况各位可以自行测试,这里就不再做过多说明。

十、 At Last

项目源码:GitHub (注意选择分支:shiro-jwt)