Shiro与JWT整合实现分布式认证

墨韵云阁 0 阅读 开发笔记

一、思路

shiro 用来认证用户及权限控制,jwt用来生成一个token,暂存用户信息。

为什么不使用session而使用jwt?传统情况下是只有一个服务器,用户登陆后将一些信息以session的形式存储服务器上,

然后将sessionid存储在本地cookie中,当用户下次请求时将会将sessionid传递给服务器,用于确认身份。

但如果是分布式的情况下会出现问题,在服务器集群中,需要一个session数据库来存储每一个session,提供给集群中所有服务使用,且无法跨域(多个Ip)使用。

jwt是生成一个token存储在客户端,每次请求将其存储在header中,解决了跨域,且可以通过自定义的方法进行验证,解决了分布式验证的问题。

缺点:无法在服务器注销、比sessionid大占带宽、一次性(想修改里面的内容,就必须签发一个新的jwt

二、废话不多说上代码

pom.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
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
<exclusions>
<exclusion>
<groupId>org.junit.vintage</groupId>
<artifactId>junit-vintage-engine</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
<version>2.1.2</version>
<exclusions>
<exclusion>
<groupId>org.mybatis</groupId>
<artifactId>mybatis-spring</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
<!-- 工具 -->
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>5.2.3</version>
</dependency>
<!-- 密码加密 -->
<dependency>
<groupId>com.github.ulisesbocchio</groupId>
<artifactId>jasypt-spring-boot-starter</artifactId>
<version>2.1.0</version>
</dependency>

<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>1.2.62</version>
</dependency>

<!-- mybatis-plus -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.3.1</version>
<exclusions>
<exclusion>
<groupId>org.mybatis</groupId>
<artifactId>mybatis</artifactId>
</exclusion>
</exclusions>
</dependency>

<!-- 数据源 -->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>druid-spring-boot-starter</artifactId>
<version>1.1.21</version>
</dependency>

<!-- xss过滤组件 -->
<dependency>
<groupId>org.jsoup</groupId>
<artifactId>jsoup</artifactId>
<version>1.9.2</version>
</dependency>

<!-- restful api 文档 swagger2 -->
<dependency>
<groupId>io.springfox</groupId>
<artifactId>springfox-swagger2</artifactId>
<version>2.9.2</version>
</dependency>

<dependency>
<groupId>com.github.xiaoymin</groupId>
<artifactId>swagger-bootstrap-ui</artifactId>
<version>1.9.3</version>
</dependency>

重构token生成继承 AuthenticationToken

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
package com.luoyx.vjsb.authority.token;

import lombok.Data;
import org.apache.shiro.authc.AuthenticationToken;

/**
* <p>
* 自定义token
* </p>
*
* @author luoyuanxiang <p>luoyuanxiang.github.io</p>
* @since 2020/3/19 17:06
*/
@Data
public class Oauth2Token implements AuthenticationToken {
private static final long serialVersionUID = 8585428037102822625L;

/**
* json web token值
*/
private String jwt;

public Oauth2Token(String jwt) {
this.jwt = jwt;
}

/**
* jwt
*
* @return jwt
*/
@Override
public Object getPrincipal() {
return this.jwt;
}

/**
* 返回jwt
*
* @return jwt
*/
@Override
public Object getCredentials() {
return this.jwt;
}
}

自定义过滤器,继承 AuthenticatingFilter

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
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
package com.luoyx.vjsb.authority.shiro.filter;

import cn.hutool.core.util.StrUtil;
import cn.hutool.json.JSONUtil;
import com.alibaba.fastjson.JSON;
import com.luoyx.vjsb.authority.token.Oauth2Token;
import com.luoyx.vjsb.authority.util.JsonWebTokenUtil;
import com.luoyx.vjsb.authority.vo.JwtAccount;
import com.luoyx.vjsb.common.properties.VjsbProperties;
import com.luoyx.vjsb.common.util.AjaxResult;
import com.luoyx.vjsb.common.util.IpUtil;
import io.jsonwebtoken.SignatureAlgorithm;
import lombok.Setter;
import lombok.extern.slf4j.Slf4j;
import org.apache.shiro.authc.AuthenticationException;
import org.apache.shiro.authc.AuthenticationToken;
import org.apache.shiro.web.filter.authc.AuthenticatingFilter;
import org.apache.shiro.web.util.WebUtils;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.RequestMethod;

import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.io.PrintWriter;
import java.util.UUID;
import java.util.concurrent.TimeUnit;

/**
* <p>
* 自定义过滤器配置
* </p>
*
* @author luoyuanxiang <p>luoyuanxiang.github.io</p>
* @since 2020/3/19 17:34
*/
@Slf4j
@Setter
public class Oauth2Filter extends AuthenticatingFilter {

private final String expiredJwt = "expiredJwt";

private StringRedisTemplate redisTemplate;

private VjsbProperties properties;

/**
* 对跨域提供支持
*/
@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"));
// 跨域时会首先发送一个option请求,这里我们给option请求直接返回正常状态
if (httpServletRequest.getMethod().equals(RequestMethod.OPTIONS.name())) {
httpServletResponse.setStatus(HttpStatus.OK.value());
return false;
}
return super.preHandle(request, response);
}

/**
* 先执行:isAccessAllowed 再执行onAccessDenied
* 如果返回true的话,就直接返回交给下一个filter进行处理。
* 如果返回false的话,回往下执行onAccessDenied
*
* @param request the incoming <code>ServletRequest</code>
* @param response the outgoing <code>ServletResponse</code>
* @param mappedValue the filter-specific config value mapped to this filter in the URL rules mappings.
* @return <code>true</code> if the request should proceed through the filter normally, <code>false</code> if the
* request should be processed by this filter's
* {@link #onAccessDenied(ServletRequest, ServletResponse, Object)} method instead.
*/
@Override
protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) {
return ((HttpServletRequest) request).getMethod().equals(RequestMethod.OPTIONS.name());
}

/**
* onAccessDenied:表示当访问拒绝时是否已经处理了;如果返回true表示需要继续处理;
* 如果返回false表示该拦截器实例已经处理了,将直接返回即可。
*
* @param request the incoming <code>ServletRequest</code>
* @param response the outgoing <code>ServletResponse</code>
* @return <code>true</code> if the request should continue to be processed; false if the subclass will
* handle/render the response directly.
* @throws Exception if there is an error processing the request.
*/
@Override
protected boolean onAccessDenied(ServletRequest request, ServletResponse response) throws Exception {
String token = getRequestToken((HttpServletRequest) request);
if (StrUtil.isBlank(token)) {
AjaxResult.responseWrite(JSON.toJSONString(AjaxResult.success("无权限访问", 1007, null)), response);
return false;
}
return executeLogin(request, response);
}

@Override
protected AuthenticationToken createToken(ServletRequest request, ServletResponse response) throws Exception {
// 这个创建token是在登录完成之后,去调用控制层时调用的,也就是要有token的时候,才会调用这个方法
return new Oauth2Token(getRequestToken(WebUtils.toHttp(request)));
}


/**
* 获取请求的token
*/
private String getRequestToken(HttpServletRequest httpRequest) {
//从header中获取token
String token = httpRequest.getHeader("Authorization");

//如果header中不存在token,则从参数中获取token
if (StrUtil.isBlank(token)) {
token = httpRequest.getParameter("Authorization");
}

return token;
}

/**
* 登录失败处理
*
* @param token token
* @param e 异常
* @param request 1
* @param response 2
* @return boolean
*/
@Override
protected boolean onLoginFailure(AuthenticationToken token, AuthenticationException e, ServletRequest request, ServletResponse response) {
//处理登录失败的异常
Throwable throwable = e.getCause() == null ? e : e.getCause();
PrintWriter writer = null;
try {
writer = WebUtils.toHttp(response).getWriter();
} catch (IOException ignored) {
}
assert writer !=null;
// 这里做token验证处理,在验证器中验证token
String message = e.getMessage();
// 令牌过期
if (expiredJwt.equals(message)) {
String jwt = JsonWebTokenUtil.parseJwtPayload(token.getCredentials().toString());
JwtAccount jwtAccount = JSONUtil.toBean(jwt, JwtAccount.class);
String s = redisTemplate.opsForValue().get("JWT-SESSION-" + IpUtil.getIpFromRequest((HttpServletRequest) request).toUpperCase() + jwtAccount.getSub());
if (s != null) {
// 重新申请新的JWT
String newJwt = JsonWebTokenUtil.createToken(UUID.randomUUID().toString(), jwtAccount.getSub(),
"token-server", jwtAccount.getPassword(), properties.getExpire(), SignatureAlgorithm.HS512);
// 将签发的JWT存储到Redis: {JWT-SESSION-{appID} , jwt}
redisTemplate.opsForValue().set("JWT-SESSION-" + IpUtil.getIpFromRequest((HttpServletRequest) request) + "_" + jwtAccount.getSub(), newJwt, properties.getExpire() << 1, TimeUnit.SECONDS);
writer.print(JSON.toJSONString(AjaxResult.success("刷新令牌", 1006, newJwt)));
} else {
writer.print(JSON.toJSONString(AjaxResult.success("令牌无效!", 1008, null)));
}
writer.flush();
return false;
}
writer.print(JSON.toJSONString(AjaxResult.success(message, 1008, null)));
writer.flush();
return false;
}

}

配置config

shiroFilter管理

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
package com.luoyx.vjsb.authority.shiro.filter;

import com.luoyx.vjsb.common.holder.SpringContextHolder;
import com.luoyx.vjsb.common.properties.VjsbProperties;
import lombok.extern.slf4j.Slf4j;
import org.apache.shiro.spring.web.ShiroFilterFactoryBean;
import org.apache.shiro.web.filter.mgt.DefaultFilterChainManager;
import org.apache.shiro.web.filter.mgt.PathMatchingFilterChainResolver;
import org.apache.shiro.web.servlet.AbstractShiroFilter;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;

import javax.annotation.Resource;
import javax.servlet.Filter;
import java.util.*;

/**
* <p>
* Shiro Filter 管理器
* </p>
*
* @author luoyuanxiang <p>luoyuanxiang.github.io</p>
* @since 2020/3/20 11:00
*/
@Slf4j
@Component
public class ShiroFilterChainManager {

@Resource
private StringRedisTemplate stringRedisTemplate;

@Resource
private VjsbProperties vjsbProperties;


/**
* 初始化获取过滤链
*
* @return java.util.Map<java.lang.String, javax.servlet.Filter>
*/
public Map<String, Filter> initGetFilters() {

Map<String, Filter> filters = new LinkedHashMap<>();
Oauth2Filter jwtFilter = new Oauth2Filter();
jwtFilter.setRedisTemplate(stringRedisTemplate);
jwtFilter.setProperties(vjsbProperties);
filters.put("oauth2", jwtFilter);

return filters;
}

/**
* 初始化获取过滤链规则
*
* @return java.util.Map<java.lang.String, java.lang.String>
*/
public Map<String, String> initGetFilterChain() {

Map<String, String> filterChain = new LinkedHashMap<>();
// -------------anon 默认过滤器忽略的URL
List<String> defaultAnon = Arrays.asList("/css/**", "/js/**", "/login");
defaultAnon.forEach(ignored -> filterChain.put(ignored, "anon"));
// -------------auth 默认需要认证过滤器的URL 走auth--PasswordFilter
List<String> defaultAuth = Collections.singletonList("/**");
defaultAuth.forEach(auth -> filterChain.put(auth, "oauth2"));

return filterChain;
}

/**
* 动态重新加载过滤链规则
*/
public void reloadFilterChain() {
ShiroFilterFactoryBean shiroFilterFactoryBean = SpringContextHolder.getBean(ShiroFilterFactoryBean.class);
AbstractShiroFilter abstractShiroFilter = null;
try {
abstractShiroFilter = (AbstractShiroFilter) shiroFilterFactoryBean.getObject();
assert abstractShiroFilter != null;
PathMatchingFilterChainResolver filterChainResolver = (PathMatchingFilterChainResolver) abstractShiroFilter.getFilterChainResolver();
DefaultFilterChainManager filterChainManager = (DefaultFilterChainManager) filterChainResolver.getFilterChainManager();
filterChainManager.getFilterChains().clear();
shiroFilterFactoryBean.getFilterChainDefinitionMap().clear();
shiroFilterFactoryBean.setFilterChainDefinitionMap(this.initGetFilterChain());
shiroFilterFactoryBean.getFilterChainDefinitionMap().forEach(filterChainManager::createChain);
} catch (Exception e) {
log.error(e.getMessage(), e);
}
}
}

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
package com.luoyx.vjsb.authority.shiro.config;

import com.luoyx.vjsb.authority.shiro.filter.ShiroFilterChainManager;
import com.luoyx.vjsb.authority.shiro.realm.Oauth2Realm;
import lombok.extern.slf4j.Slf4j;
import org.apache.shiro.SecurityUtils;
import org.apache.shiro.mgt.DefaultSessionStorageEvaluator;
import org.apache.shiro.mgt.DefaultSubjectDAO;
import org.apache.shiro.mgt.SecurityManager;
import org.apache.shiro.session.mgt.DefaultSessionManager;
import org.apache.shiro.spring.web.ShiroFilterFactoryBean;
import org.apache.shiro.web.mgt.DefaultWebSecurityManager;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

/**
* <p>
* shiro 权限配置
* </p>
*
* @author luoyuanxiang <p>luoyuanxiang.github.io</p>
* @since 2020/3/19 15:17
*/
@Slf4j
@Configuration
public class ShiroConfiguration {

@Bean
public ShiroFilterFactoryBean shiroFilterFactoryBean(SecurityManager securityManager, ShiroFilterChainManager filterChainManager) {
ShiroFilterFactoryBean shiroFilterFactoryBean = new ShiroFilterFactoryBean();
shiroFilterFactoryBean.setSecurityManager(securityManager);
shiroFilterFactoryBean.setFilters(filterChainManager.initGetFilters());
shiroFilterFactoryBean.setFilterChainDefinitionMap(filterChainManager.initGetFilterChain());
return shiroFilterFactoryBean;
}

@Bean
public SecurityManager securityManager() {
DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager();
securityManager.setRealm(jwtRealm());

log.info("设置sessionManager禁用掉会话调度器");
securityManager.setSessionManager(sessionManager());

// 无状态subjectFactory设置
DefaultSessionStorageEvaluator evaluator = (DefaultSessionStorageEvaluator) ((DefaultSubjectDAO) securityManager.getSubjectDAO()).getSessionStorageEvaluator();
evaluator.setSessionStorageEnabled(Boolean.FALSE);
StatelessDefaultSubjectFactory subjectFactory = new StatelessDefaultSubjectFactory();
securityManager.setSubjectFactory(subjectFactory);

SecurityUtils.setSecurityManager(securityManager);

return securityManager;
}

@Bean
public Oauth2Realm jwtRealm() {
return new Oauth2Realm();
}

/**
* session管理器
* sessionManager通过sessionValidationSchedulerEnabled禁用掉会话调度器,
* 因为我们禁用掉了会话,所以没必要再定期过期会话了。
*
* @return 1
*/
@Bean
public DefaultSessionManager sessionManager() {
DefaultSessionManager sessionManager = new DefaultSessionManager();
sessionManager.setSessionValidationSchedulerEnabled(Boolean.FALSE);
return sessionManager;
}
}

StatelessDefaultSubjectFactory

package com.luoyx.vjsb.authority.shiro.config;

import org.apache.shiro.subject.Subject;
import org.apache.shiro.subject.SubjectContext;
import org.apache.shiro.web.mgt.DefaultWebSubjectFactory;

/**
* <p>
*
* </p>
*
* @author luoyuanxiang <p>luoyuanxiang.github.io</p>
* @since 2020/3/20 16:24
*/
public class StatelessDefaultSubjectFactory extends DefaultWebSubjectFactory {

@Override
public Subject createSubject(SubjectContext context) {
//不创建session
context.setSessionCreationEnabled(false);
return super.createSubject(context);
}
}

token工具生成类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
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
package com.luoyx.vjsb.authority.util;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.luoyx.vjsb.authority.vo.JwtAccount;
import io.jsonwebtoken.*;
import io.jsonwebtoken.impl.DefaultHeader;
import io.jsonwebtoken.impl.DefaultJwsHeader;
import io.jsonwebtoken.impl.TextCodec;
import io.jsonwebtoken.impl.compression.DefaultCompressionCodecResolver;
import io.jsonwebtoken.lang.Assert;
import org.springframework.util.CollectionUtils;
import org.springframework.util.StringUtils;

import javax.xml.bind.DatatypeConverter;
import java.io.IOException;
import java.util.*;

/**
* <p>
* token生成器
* </p>
*
* @author luoyuanxiang <p>luoyuanxiang.github.io</p>
* @since 2020/3/19 15:37
*/
public class JsonWebTokenUtil {


public static final String SECRET_KEY = "?::4343fdf4fdf6cvf):";
private static final ObjectMapper MAPPER = new ObjectMapper();
private static final int COUNT_2 = 2;

private static CompressionCodecResolver codecResolver = new DefaultCompressionCodecResolver();

private JsonWebTokenUtil() {

}

/**
* json web token 签发
*
* @param id 令牌ID
* @param subject 用户ID
* @param issuer 签发人
* @param period 有效时间(秒)
* @param password 用户密码
* @param algorithm 加密算法
* @return java.lang.String
*/
public static String createToken(String id, String subject, String issuer, String password, Long period, SignatureAlgorithm algorithm) {
// 当前时间戳
long currentTimeMillis = System.currentTimeMillis();
// 秘钥
byte[] secreKeyBytes = DatatypeConverter.parseBase64Binary(SECRET_KEY);
JwtBuilder jwtBuilder = Jwts.builder();
Optional.ofNullable(id)
.ifPresent(i-> {
jwtBuilder.setId(id);
});
if (!StringUtils.isEmpty(id)) {
jwtBuilder.setId(id);
}
if (!StringUtils.isEmpty(subject)) {
jwtBuilder.setSubject(subject);
}
if (!StringUtils.isEmpty(issuer)) {
jwtBuilder.setIssuer(issuer);
}
if (!StringUtils.isEmpty(password)) {
jwtBuilder.claim("password", password);
}
// 设置签发时间
jwtBuilder.setIssuedAt(new Date(currentTimeMillis));
// 设置到期时间
if (null != period) {
jwtBuilder.setExpiration(new Date(currentTimeMillis + period * 1000));
}
// 压缩,可选GZIP
jwtBuilder.compressWith(CompressionCodecs.DEFLATE);
// 加密设置
jwtBuilder.signWith(algorithm, secreKeyBytes);

return jwtBuilder.compact();
}

/**
* 解析JWT的Payload
*/
public static String parseJwtPayload(String jwt) {
Assert.hasText(jwt, "JWT String argument cannot be null or empty.");
String base64UrlEncodedHeader = null;
String base64UrlEncodedPayload = null;
String base64UrlEncodedDigest = null;
int delimiterCount = 0;
StringBuilder sb = new StringBuilder(128);
for (char c : jwt.toCharArray()) {
if (c == '.') {
CharSequence tokenSeq = io.jsonwebtoken.lang.Strings.clean(sb);
String token = tokenSeq != null ? tokenSeq.toString() : null;

if (delimiterCount == 0) {
base64UrlEncodedHeader = token;
} else if (delimiterCount == 1) {
base64UrlEncodedPayload = token;
}

delimiterCount++;
sb.setLength(0);
} else {
sb.append(c);
}
}
if (delimiterCount != COUNT_2) {
String msg = "JWT strings must contain exactly 2 period characters. Found: " + delimiterCount;
throw new MalformedJwtException(msg);
}
if (sb.length() > 0) {
base64UrlEncodedDigest = sb.toString();
}
if (base64UrlEncodedPayload == null) {
throw new MalformedJwtException("JWT string '" + jwt + "' is missing a body/payload.");
}
// =============== Header =================
Header header = null;
CompressionCodec compressionCodec = null;
if (base64UrlEncodedHeader != null) {
String origValue = TextCodec.BASE64URL.decodeToString(base64UrlEncodedHeader);
Map<String, Object> m = readValue(origValue);
if (base64UrlEncodedDigest != null) {
header = new DefaultJwsHeader(m);
} else {
header = new DefaultHeader(m);
}
compressionCodec = codecResolver.resolveCompressionCodec(header);
}
// =============== Body =================
String payload;
if (compressionCodec != null) {
byte[] decompressed = compressionCodec.decompress(TextCodec.BASE64URL.decode(base64UrlEncodedPayload));
payload = new String(decompressed, io.jsonwebtoken.lang.Strings.UTF_8);
} else {
payload = TextCodec.BASE64URL.decodeToString(base64UrlEncodedPayload);
}
return payload;
}

/**
* 验签JWT
*
* @param jwt json web token
* @param appKey key
* @return JwtAccount
* @throws ExpiredJwtException 异常
* @throws UnsupportedJwtException 异常
* @throws MalformedJwtException 异常
* @throws SignatureException 异常
* @throws IllegalArgumentException 异常
*/
public static JwtAccount parseJwt(String jwt, String appKey) throws ExpiredJwtException, UnsupportedJwtException, MalformedJwtException, SignatureException, IllegalArgumentException {
Claims claims = Jwts.parser()
.setSigningKey(DatatypeConverter.parseBase64Binary(appKey))
.parseClaimsJws(jwt)
.getBody();
JwtAccount jwtAccount = new JwtAccount();
// 令牌ID
jwtAccount.setJti(claims.getId())
// 客户标识
.setSub(claims.getSubject())
// 签发者
.setIss(claims.getIssuer())
// 签发时间
.setIat(claims.getIssuedAt().getTime())
.setExp(claims.getExpiration().getTime())
// 密码
.setPassword(claims.get("password", String.class));
return jwtAccount;
}


/**
* description 从json数据中读取格式化map
*
* @param val 1
* @return java.util.Map<java.lang.String, java.lang.Object>
*/
@SuppressWarnings("unchecked")
public static Map<String, Object> readValue(String val) {
try {
return MAPPER.readValue(val, Map.class);
} catch (IOException e) {
throw new MalformedJwtException("Unable to read JSON value: " + val, e);
}
}

/**
* 分割字符串进SET
*/
@SuppressWarnings("unchecked")
public static Set<String> split(String str) {

Set<String> set = new HashSet<>();
if (StringUtils.isEmpty(str)) {
return set;
}
set.addAll(CollectionUtils.arrayToList(str.split(",")));
return set;
}
}

到此项目差不多搞定

项目源码:源码好的,我们继续完善这个基于 Shiro + JWT 的认证授权系统。接下来将补充核心的领域(Realm)实现、登录控制器以及一些必要的配置和工具类。


三、核心实现

1. 自定义 Realm(Oauth2Realm

Realm 是 Shiro 的核心组件,用于处理认证(登录)和授权(权限检查)。我们需要自定义一个 Realm 来处理 JWT Token。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
package com.luoyx.vjsb.authority.shiro.realm;

import com.luoyx.vjsb.authority.shiro.token.Oauth2Token;
import com.luoyx.vjsb.authority.util.JsonWebTokenUtil;
import com.luoyx.vjsb.authority.vo.JwtAccount;
import com.luoyx.vjsb.common.properties.VjsbProperties;
import lombok.extern.slf4j.Slf4j;
import org.apache.shiro.authc.AuthenticationException;
import org.apache.shiro.authc.AuthenticationInfo;
import org.apache.shiro.authc.AuthenticationToken;
import org.apache.shiro.authc.SimpleAuthenticationInfo;
import org.apache.shiro.authz.AuthorizationInfo;
import org.apache.shiro.authz.SimpleAuthorizationInfo;
import org.apache.shiro.realm.AuthorizingRealm;
import org.apache.shiro.subject.PrincipalCollection;
import org.springframework.stereotype.Component;

import javax.annotation.Resource;

/**
* <p>
* 自定义 Realm,处理 JWT Token 的认证与授权
* </p>
* @author luoyuanxiang
*/
@Slf4j
@Component
public class Oauth2Realm extends AuthorizingRealm {

@Resource
private VjsbProperties vjsbProperties;

/**
* 限定此 Realm 只支持我们自定义的 Oauth2Token 类型
*/
@Override
public boolean supports(AuthenticationToken token) {
return token instanceof Oauth2Token;
}

/**
* 授权方法:当用户访问需要权限的接口时,会调用此方法
* 由于我们的权限信息也存储在 JWT 中,可以直接解析,无需额外查询数据库(除非需要实时权限)
*/
@Override
protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) {
// 从 principals 中获取主身份信息(即之前认证时存入的 JWT 字符串)
String jwt = (String) principals.getPrimaryPrincipal();
JwtAccount jwtAccount = null;
try {
// 解析 JWT,获取负载中的信息
jwtAccount = JsonWebTokenUtil.parseJwt(jwt, JsonWebTokenUtil.SECRET_KEY);
} catch (Exception e) {
log.error("JWT 解析失败,无法进行授权", e);
throw new AuthenticationException("Token 无效", e);
}

SimpleAuthorizationInfo authorizationInfo = new SimpleAuthorizationInfo();
// 这里假设 JWT 的 payload 中包含了角色和权限信息(例如 roles: "admin,user", permissions: "user:read,user:write")
// 实际项目中可能需要从数据库实时查询用户最新权限
// authorizationInfo.addRoles(jwtAccount.getRoles());
// authorizationInfo.addStringPermissions(jwtAccount.getPermissions());

// 示例:添加一个固定权限(实际应根据 jwtAccount 中的信息动态添加)
authorizationInfo.addRole("admin");
authorizationInfo.addStringPermission("user:read");

return authorizationInfo;
}

/**
* 认证方法:在登录时调用,验证 token 的有效性
*/
@Override
protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException {
String jwtToken = (String) token.getCredentials();
if (jwtToken == null) {
throw new AuthenticationException("Token 不能为空");
}

// 校验 JWT 的有效性(是否被篡改、是否过期)
JwtAccount jwtAccount;
try {
jwtAccount = JsonWebTokenUtil.parseJwt(jwtToken, JsonWebTokenUtil.SECRET_KEY);
} catch (Exception e) {
log.error("JWT 校验失败", e);
// 根据异常类型抛出不同的 AuthenticationException
if (e instanceof ExpiredJwtException) {
throw new AuthenticationException("expiredJwt"); // 这个字符串会在过滤器中捕获用于刷新 token
} else {
throw new AuthenticationException("Token 无效或已过期");
}
}

// 这里可以添加额外的校验,例如检查用户是否被锁定、是否被删除等(需要查询数据库)
// if (!userService.isUserActive(jwtAccount.getSub())) {
// throw new AuthenticationException("用户已被禁用");
// }

// 认证成功,返回一个 AuthenticationInfo 对象,Shiro 会将其保存到 PrincipalCollection 中
return new SimpleAuthenticationInfo(
jwtToken, // Principal: 身份标识,这里我们直接使用 JWT 字符串本身
jwtToken, // Credentials: 凭证,同样为 JWT
getName() // Realm Name
);
}
}

2. 登录控制器(LoginController

提供一个简单的登录接口,验证用户名密码后签发 JWT。

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.luoyx.vjsb.authority.controller;

import com.luoyx.vjsb.authority.util.JsonWebTokenUtil;
import com.luoyx.vjsb.authority.vo.JwtAccount;
import com.luoyx.vjsb.common.util.AjaxResult;
import com.luoyx.vjsb.common.util.IpUtil;
import io.jsonwebtoken.SignatureAlgorithm;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

import javax.annotation.Resource;
import javax.servlet.http.HttpServletRequest;
import java.util.UUID;
import java.util.concurrent.TimeUnit;

/**
* <p>
* 登录控制器
* </p>
* @author luoyuanxiang
*/
@Slf4j
@RestController
public class LoginController {

@Resource
private StringRedisTemplate stringRedisTemplate;

@Resource
private com.luoyx.vjsb.common.properties.VjsbProperties vjsbProperties;

/**
* 用户登录接口
*/
@PostMapping("/login")
public AjaxResult login(@RequestParam String username,
@RequestParam String password,
HttpServletRequest request) {
// 1. 校验用户名和密码 (这里省略了具体的校验逻辑,实际应查询数据库)
// User user = userService.findByUsernameAndPassword(username, password);
// if (user == null) {
// return AjaxResult.error("用户名或密码错误");
// }

// 模拟一个用户ID
String userId = "10001";

// 2. 生成 JWT
String jwt = JsonWebTokenUtil.createToken(
UUID.randomUUID().toString(), // JWT ID
userId, // Subject (通常为用户唯一标识)
"vjsb-server", // Issuer
password, // 可以将密码哈希后存入,但切勿明文存储敏感信息
vjsbProperties.getExpire(), // 过期时间(秒)
SignatureAlgorithm.HS512 // 签名算法
);

// 3. 将 JWT 存储到 Redis(用于实现 token 刷新或强制失效机制)
String redisKey = "JWT-SESSION-" + IpUtil.getIpFromRequest(request) + "_" + userId;
stringRedisTemplate.opsForValue().set(
redisKey,
jwt,
vjsbProperties.getExpire() * 2, // Redis 中的过期时间稍长于 JWT 过期时间
TimeUnit.SECONDS
);

// 4. 返回 JWT 给客户端
return AjaxResult.success("登录成功", jwt);
}

/**
* 用户登出接口(可选)
* 由于 JWT 是无状态的,服务器无法直接使其失效。
* 常见的解决方案是在 Redis 中维护一个黑名单,或者使客户端主动丢弃 token。
* 这里演示通过 Redis 黑名单实现:
*/
@PostMapping("/logout")
public AjaxResult logout(HttpServletRequest request) {
String token = request.getHeader("Authorization");
if (token != null) {
try {
// 解析 token 获取用户信息
JwtAccount jwtAccount = JsonWebTokenUtil.parseJwt(token, JsonWebTokenUtil.SECRET_KEY);
String userId = jwtAccount.getSub();
// 将 token 加入黑名单(或直接删除 Redis 中的有效 token 记录)
String redisKey = "JWT-BLACKLIST-" + token;
stringRedisTemplate.opsForValue().set(redisKey, "logout", jwtAccount.getExp() - System.currentTimeMillis(), TimeUnit.MILLISECONDS);
// 删除原有的 token 记录(如果存在)
stringRedisTemplate.delete("JWT-SESSION-" + IpUtil.getIpFromRequest(request) + "_" + userId);
return AjaxResult.success("登出成功");
} catch (Exception e) {
log.warn("登出时解析 token 失败", e);
}
}
return AjaxResult.error("登出失败");
}
}

3. 全局异常处理(可选但推荐)

统一处理 Shiro 抛出的认证授权异常,返回友好的 JSON 格式错误信息。

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
package com.luoyx.vjsb.common.handler;

import com.luoyx.vjsb.common.util.AjaxResult;
import lombok.extern.slf4j.Slf4j;
import org.apache.shiro.authc.AuthenticationException;
import org.apache.shiro.authz.AuthorizationException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

/**
* <p>
* 全局异常处理器
* </p>
* @author luoyuanxiang
*/
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {

/**
* 处理认证失败异常(未登录、token无效等)
*/
@ExceptionHandler(AuthenticationException.class)
public AjaxResult handleAuthenticationException(AuthenticationException e) {
log.warn("认证失败: {}", e.getMessage());
return AjaxResult.error(1007, "认证失败: " + e.getMessage());
}

/**
* 处理授权失败异常(无权限访问)
*/
@ExceptionHandler(AuthorizationException.class)
public AjaxResult handleAuthorizationException(AuthorizationException e) {
log.warn("授权失败: {}", e.getMessage());
return AjaxResult.error(1008, "无权限访问");
}

/**
* 处理其他异常
*/
@ExceptionHandler(Exception.class)
public AjaxResult handleException(Exception e) {
log.error("系统异常: ", e);
return AjaxResult.error(500, "系统内部错误");
}
}

四、总结与使用

至此,一个基于 Spring Boot + Shiro + JWT + Redis 的无状态分布式认证授权系统就搭建完成了。

核心流程总结:

  1. 登录 (/login):用户提供凭证,服务器校验通过后,生成 JWT 并返回给客户端,同时在 Redis 中存储一份(用于后续刷新和黑名单机制)。
  2. 访问API:客户端在请求头 Authorization 中携带 JWT。
  3. 过滤 (Oauth2Filter):自定义过滤器拦截请求,提取 JWT,并调用 subject.login(token) 发起认证。
  4. 认证 (Oauth2Realm):Realm 验证 JWT 的有效性(签名、过期时间)。
  5. 授权 (Oauth2Realm):认证通过后,Realm 根据 JWT 中的信息(或查询数据库)为用户授予角色和权限。
  6. 权限校验:Shiro 的注解(如 @RequiresRoles, @RequiresPermissions)或 URL 配置会拦截无权限的访问。
  7. 登出 (/logout)(可选):将 JWT 加入黑名单或删除 Redis 中的有效记录,使 token 提前失效。

如何使用:

  1. 在需要权限的控制器方法上添加 Shiro 注解:
    1
    2
    3
    4
    5
    @RequiresPermissions("user:read")
    @GetMapping("/user/info")
    public AjaxResult getUserInfo() {
    // ...
    }
  2. 前端在请求需要认证的 API 时,在 Header 中设置:
    1
    Authorization: your_jwt_token_string

注意事项:

  • JWT 安全性:确保使用强密钥(SECRET_KEY)且妥善保管。考虑定期更换密钥。
  • 敏感信息:不要在 JWT Payload 中存储敏感信息(如密码明文),因为它是可解码的。
  • Token 失效:由于 JWT 的无状态性,实现即时失效较复杂。文中提供了基于 Redis 黑名单的示例,但会增加系统状态性。请根据业务需求权衡。
  • 性能:如果每次授权都需要查询数据库获取实时权限,可能会影响性能。可以考虑将权限信息也放入 JWT,但牺牲了实时性。

项目源码已提供,可以根据实际业务需求进行进一步的调整和优化,例如集成数据库用户查询、更复杂的权限

评论