封面来源:本文封面来源于网络,如有侵权,请联系删除。

本文参考:【编程不良人】JWT认证原理、流程整合springboot实战应用,前后端分离认证的解决方案!

1. JWT 概述

1.1 什么是 JWT

官网地址:JWT 官网

JWT 是 JSON Web Token 的简写,官网上如下写道:

JSON Web Token (JWT) is an open standard (RFC 7519) that defines a compact and self-contained way for securely transmitting information between parties as a JSON object. This information can be verified and trusted because it is digitally signed. JWTs can be signed using a secret (with the HMAC algorithm) or a public/private key pair using RSA or ECDSA.

翻译一下:JSON Web Token(JWT)是一个开放标准(RFC 7519),它定义了一种紧凑的、自包含的方式,用于在各方之间以 JSON 对象安全地传输信息。此信息可以被验证和被信任,因为它是数字签名的。JWT 可以使用秘密(使用 HMAC 算法)或使用 RSA 或 ECDSA 的公钥 / 私钥对进行签名。

通俗来说:

JWT 简称 JSON Web Token,也就是通过 JSON 的形式作为 Web 应用中的令牌,用于在各方之间安全地将信息作为 JSON 对象传输。在数据传输过程中还可以完成数据加密、签名等相关处理。

1.2 JWT 能做什么

授权

这是使用 JWT 的最常见的使用方案。一旦用户登录,每个后续请求都将包括 JWT,从而允许用户访问该令牌允许的路由、服务和资源。JWT 被广泛应用于单点登录中,因为它的开销很小并且可以在不同的域中轻松使用。

信息交换

JSON Web Token 是在各方之间安全地传输信息的好方法。因为可以对 JWT 进行签名(例如,使用公钥 / 私钥对),所以您可以确保发件人是他们所说的人。此外,由于签名是使用标头和有效负载计算的,因此您还可以验证内容是否遭到篡改。

1.3 为什么要用 JWT

先前,我们习惯使用基于传统的 Session 认证,那么这种认证和 JWT 的认证有什么区别,或者说为什么要选择 JWT 呢?

基于传统的 Session 认证

我们知道,http 协议本身是一种无状态的协议,而这就意味着如果用户向我们的应用提供了用户名和密码来进行用户认证,那么下一次请求时,用户还要再一次进行用户认证才行,因为根据 http 协议,我们并不能知道是哪个用户发出的请求,所以为了让我们的应用能识别是哪个用户发出的请求,我们只能在服务器存储一份用户登录的信息,这份登录信息会在响应时传递给浏览器,告诉其保存为 Cookie,以便下次请求时发送给我们的应用,这样我们的应用就能识别请求来自哪个用户了,这就是传统的 Session 认证。

认证流程:

传统Session认证流程

但这种认证方式也暴露了很多问题:

1、每个用户经过我们的应用认证之后,我们的应用都要在服务端做一次记录,以方便用户下次请求的鉴别,通常而言 Session 都是保存在内存中,而随着认证用户的增多,服务端的开销会明显增大。

2、用户认证之后,服务端做认证记录,如果认证的记录被保存在内存中的话,这意味着用已认证用户下次还必须请求到这台服务器上,才能拿到授权的资源,而这在分布式的应用上,限制了负载均衡器的能力,这也意味着限制了应用的扩展能力。

3、因为是基于 Cookie 来进行用户识别的,如果 Cookie 被截获,用户就会很容易受到跨站请求伪造的攻击。

4、在前后端分离系统中就更加痛苦,前后端分离在应用解耦后增加了部署的复杂性。通常用户一次请求就要转发多次。如果用 Session 每次携带 SessionId 到服务器,服务器还要查询用户信息。如果用户很多,这些信息存储在服务器内存中,会给服务器增加负担。还会产生 CSRF(跨站伪造请求攻击)攻击,Session 是基于 Cookie 进行用户识别的,Cookie如果被截获,用户就会很容易受到跨站请求伪造的攻击。并且 SessionId 就是一个特征值,表达的信息不够丰富,不容易扩展。如果后端应用是多节点部署,那么还需要实现 Session 共享机制,不方便集群应用。

前后端分离系统简单架构

基于 JWT 认证

JWT认证流程

认证流程:

1、首先,前端通过 Web 表单将自己的用户名和密码发送到后端的接口。这一过程一般是一个 HTTP POST 请求。建议的方式是通过 SSL 加密的传输(https 协议),从而避免敏感信息被嗅探。

2、后端核对用户名和密码成功后,将用户的 id 等其他信息作为 JWT Payload(负载),将其与头部分别进行 Base64 编码拼接后签名,形成一个 JWT(Token)。形成的 JWT 就是一个形同 lll.zzz.xxx 的字符串。

3、后端将 JWT 字符串作为登录成功的返回结果返回给前端。前端可以将返回的结果保存在 localStoragesessionStorage 上,退出登录时前端删除保存的JWT即可。

4、前端在每次请求时将 JWT 放入 HTTP Header 中的 Authorization 位(可以解决 XSS 和 XSRF 问题)。

5、后端检查是否存在,如存在验证JWT的有效性。例如,检查签名是否正确;检查 Token 是否过期;检查 Token 的接收方是否是自己等等。

6、验证通过后后端使用 JWT 中包含的用户信息进行其他逻辑操作,返回相应结果。

JWT 优势:

1、简洁(Compact):可以通过 URL,POST 参数或者在 HTTP Header 发送,因为数据量小,传输速度也很快。

2、自包含(Self-contained):负载中包含了所有用户所需要的信息,避免了多次查询数据库。

3、因为 Token 是以 JSON 加密的形式保存在客户端的,所以 JWT 是跨语言的,原则上任何 Web 形式都支持。

4、不需要在服务端保存会话信息,特别适用于分布式微服务。

2. JWT 的结构

JWT 就是一个令牌(token),再简单来说,就是一段字符串,只不过这段字符串设计得很巧妙。这段字符串由三段组成,彼此之间用 . 分割。

对于这三段,我们将它们成为:

  • 标头(Header)
  • 有效载荷(Payload)
  • 签名(Signature)

因此,JWT 的通常形式如下:xxx.yyy.zzz,即:Header.Payload.Signature

2.1 Header

标头通常由两部分组成:令牌的类型(即 JWT)和所使用的签名算法,例如 HMAC SHA256 或 RSA。它会使用 Base64 编码组成 JWT 结构的第一部分。

注意: Base64 只是一种编码方式,并不是一种加密方式。具体可以参考本站【Base64 编码的那些事】一文。

如:

1
2
3
4
{
"alg": "HS256",
"typ": "JWT"
}

2.2 Payload

令牌的第二部分是有效负载,其中包含相关的声明。声明是有关实体(通常是用户)和其他数据的声明。这一部分也会使用 Base64 进行编码从而组成 JWT 结构的第二部分。

由于这一部分使用了 Base64 进行编码,这是可逆的,因此 不要在 Payload 中存放用户敏感信息

如:

1
2
3
4
5
{
"sub": "123456789",
"name": "mofan",
"admin": true
}

2.3 Signature

前面两部分都是使用 Base64 进行编码的,即前端可以解码从而知道其中包含的信息。Signature 需要使用编码后的 Header 和 Payload 以及我们提供的一个密钥,然后使用 Header 中指定的签名算法(HS256)进行签名。签名的作用是保证 JWT 没有被篡改过。

如:

1
HMACSHA256(base64UrlEncode(header) + "." + base64UrlEncode(payload), secret);

签名目的

最后一步签名的过程,实际上是对头部以及负载内容进行签名,防止内容被窜改。如果有人对头部以及负载的内容解码之后进行修改,再进行编码,最后加上之前的签名组合形成新的JWT的话,那么服务器端会判断出新的头部和负载形成的签名和 JWT 附带上的签名是不一样的。如果要对新的头部和负载进行签名,在不知道服务器加密时用的密钥的话,得出来的签名也是不一样的。

信息安全问题

在这里大家一定会问一个问题:Base64 是一种编码,是可逆的,那么我的信息不就被暴露了吗?

是的。所以在 JWT 中,不应该在负载里面加入任何敏感的数据。

比如我们传输的是用户的 UserID,这个值就不是什么敏感内容,就被知道也是无所谓的。但像密码这样的内容就不能被存放在 JWT 中了。如果将用户的密码放在了JWT中,那么怀有恶意的第三方人员通过 Base64 解码就能很快地知道你的密码了。

因此 JWT 适合用于向 Web 应用传递一些非敏感信息。JWT 还经常用于设计用户认证和授权系统,甚至实现 Web 应用的单点登录。


未进行编码前的 JWT 内容:

未进行编码前的JWT内容

进行编码后输出是三个由点分隔的 Base64-URL字符串,可以在 HTML 和 HTTP 环境中轻松传递这些字符串,与基于 XML 的标准(例如 SAML )相比,它更紧凑、更简洁且自包含。

进行编码后的 JWT 内容:

进行编码后的JWT内容

3. 使用 JWT

3.1 使用前的准备

使用之前需要先导入对应的依赖:

1
2
3
4
5
<dependency>
<groupId>com.auth0</groupId>
<artifactId>java-jwt</artifactId>
<version>3.15.0</version>
</dependency>

不同版本的依赖在使用上可能有细小的差别,但总体使用基本一致。

我在此使用我所在时间点的最新版本。

3.2 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
/**
* @author mofan
* @date 2021/4/19 16:06
*/
public class JWTSimpleTest {

private static String jwtString = "";

@Test
public void testGetAndVerifyJWT() {
Map<String, Object> map = new HashMap<>();

Calendar instance = Calendar.getInstance();
instance.add(Calendar.MINUTE, 10);

String token = JWT.create()
.withHeader(map) // header
.withClaim("userId", 123456789) // payload
.withClaim("username", "mofan") // payload
.withExpiresAt(instance.getTime()) // 设置令牌过期时间
.sign(Algorithm.HMAC256("$^%SAI*JA")); // 签名

Assert.assertNotNull(token);
jwtString = token;
System.out.println(token);
testVerifyJWT();
}

public void testVerifyJWT() {
// 创建验证对象
JWTVerifier jwtVerifier = JWT.require(Algorithm.HMAC256("$^%SAI*JA")).build();

DecodedJWT verify = jwtVerifier.verify(jwtString);

Assert.assertEquals(123456789, verify.getClaim("userId").asInt().intValue());
Assert.assertEquals("mofan", verify.getClaim("username").asString());

System.out.println(verify.getClaim("userId"));
System.out.println(verify.getClaim("username"));
System.out.println("过期时间:" + verify.getExpiresAt());
}
}

运行上述测试类后,测试通过,控制台打印出:

eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJleHAiOjE2MTg4MjE3MjgsInVzZXJJZCI6MTIzNDU2Nzg5LCJ1c2VybmFtZSI6Im1vZmFuIn0.N1Apdr9V4EJbpGG1Nb0VT8jApr4Yx3dq9HEF9Z0hYN8
123456789
"mofan"
过期时间:Mon Apr 19 16:42:08 CST 2021

如果你复制我的代码并运行,测试仍能够通过,但打印出来的信息可能会有所不一样,比如生成的 Token 信息的 Signature 部分和过期的时间。

但你会发现,生成的 Token 信息的 Header 和 Payload 是一样的。这也表明了 Header 和 Payload 只是经过了编码操作,而没进行加密操作。

异常信息

在生成或解析 JWT 信息时,可能会出现以下异常信息:

异常类 代表含义
SignatureVerificationException 签名不一致
TokenExpiredException 令牌过期
AlgorithmMismatchException 算法不匹配
InvalidClaimException 失效的 Payload

JWT异常类所在位置

3.3 工具类的封装

为了方便操作,我们可以封装一个 JWT 的工具类,用于生成、验证和解析 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
/**
* @author mofan
* @date 2021/4/19 16:41
*/
public class JWTUtil {

private static final String SIGN = "$%VJH*)SA";

/**
* 生成 JWT Token
* @param data Payload 信息
* @return 生成的 Token 信息
*/
public static String getToken(Map<String, Object> data) {
JWTCreator.Builder builder = JWT.create();
// 类型转换
Map<String, String> map = data.entrySet().stream()
.collect(Collectors.toMap(Map.Entry::getKey, entry -> String.valueOf(entry.getValue())));
map.forEach(builder::withClaim);
// 设置过期时间
Calendar instance = Calendar.getInstance();
instance.add(Calendar.DATE, 7);
builder.withExpiresAt(instance.getTime());

return builder.sign(Algorithm.HMAC256(SIGN));
}

/**
* 验证 token
* @param token 被验证的 Token 信息
*/
public static boolean verify(String token){
// 如果验证通过,则不会把报错,否则会报错
try {
JWT.require(Algorithm.HMAC256(SIGN)).build().verify(token);
return true;
} catch (Exception e) {
return false;
}
}
/**
* 获取 Token 中 Payload
* @param token 信息来源
* @return 相应的 Token 信息
*/
public static DecodedJWT getTokenInfo(String token){
return JWT.require(Algorithm.HMAC256(SIGN)).build().verify(token);
}
}

当然还得验证一下,免得出问题:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
/**
* @author mofan
* @date 2021/4/19 17:41
*/
public class JWTUtilTest {

@Test
public void test() {
HashMap<String, Object> map = new HashMap<>();
map.put("username", "mofan");
map.put("password", "123456");
map.put("age", 19);
String token = JWTUtil.getToken(map);
Assert.assertNotNull(token);
Assert.assertTrue(JWTUtil.verify(token));
Assert.assertFalse(JWTUtil.verify(""));
DecodedJWT tokenInfo = JWTUtil.getTokenInfo(token);
Assert.assertEquals("mofan", tokenInfo.getClaim("username").asString());
Assert.assertEquals("123456", tokenInfo.getClaim("password").asString());
Assert.assertEquals("19", tokenInfo.getClaim("age").asString());
System.out.println(tokenInfo.getExpiresAt());
}
}

运行测试类后,测试通过!

4. 实际场景使用

根据前面的介绍,JWT 只是一种生成 Token 的方案,在实际使用时,我们常常将其与安全框架 SpringSecurity、Shiro 等一起使用。

我们选择使用 SpringSecurity 与 JWT 进行整合,首先需要导入 SpringSecurity 的依赖:

1
2
3
4
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>

为了便于存储生成的 Token 信息,我们将其保存在 Redis 中,因此也导入 Redis 的依赖:

1
2
3
4
5
6
7
8
9
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-pool2</artifactId>
<version>2.8.0</version>
</dependency>

4.1 更完善的工具类

在前面列出的工具类中,签名 Sign 是直接书写在代码中的,这样具有安全隐患。为减少安全隐患,我们希望签名能够随着项目的启动而变更,每次启动项目都让签名改变一次。

因此,修改后的工具类如下:

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
/**
* @author mofan
* @date 2021/4/21 12:20
*/
@Slf4j
public class JwtUtil {

/**
* Token 生成算法
*/
private static Algorithm algorithm = null;

static {
PublicKey publicKey = null;
PrivateKey privateKey = null;
try {
// RSA keyPair Generator
final KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA");
/*
* 长度 至少 1024, 建议 2048
*/
final int keySize = 2048;
keyPairGenerator.initialize(keySize);
final KeyPair keyPair = keyPairGenerator.genKeyPair();
publicKey = keyPair.getPublic();
privateKey = keyPair.getPrivate();
} catch (NoSuchAlgorithmException e) {
log.error("JWT Token 信息生成失败");
}
// gen id_token
algorithm = Algorithm.RSA256((RSAPublicKey) publicKey, (RSAPrivateKey) privateKey);
}

/**
* 用户角色
*/
private static final String ROLE_CLAIMS = "roleInfo";
/**
* 签发人
*/
private static final String ISS = "mofan";
/**
* 默认过期时间 ---> 3600S 1H
*/
public static final long DEFAULT_EXPIRE_DATE = 3600L;
/**
* 选择记住我后过期时间 ---> 86,400S 24H 1D
*/
private static final long REMEMBER_ME_EXPIRE_DATE = 86_400L;

/**
* 生成 JWT Token
* @param userAccount 用户账号
* @param roles 用户所具有的权限
* @return Token 字符串
*/
public static String getToken(String userAccount, String id, List<String> roles, boolean rememberMe) {
long expireDate = rememberMe ? REMEMBER_ME_EXPIRE_DATE : DEFAULT_EXPIRE_DATE;

JWTCreator.Builder builder = JWT.create();
Map<String, String> map = Collections.singletonMap(ROLE_CLAIMS, String.join(",", roles));
map.forEach(builder::withClaim);
final Date date = new Date();

// 设置签发人
builder.withIssuer(ISS)
// 设置签发时间
.withIssuedAt(date)
// 设置主题
.withSubject(userAccount)
.withJWTId(id)
// 设置过期时间
.withExpiresAt(new Date(date.getTime() + expireDate * 1000));
return ResponseMetaData.TOKEN_PREFIX + builder.sign(algorithm);
}

public static String getId(String token) {
return getTokenInfo(token).getId();
}

/**
* 根据 JWT Token 信息生成认证 Token 信息
* @param token JWT Token 信息
* @return 认证 Token 信息
*/
public static UsernamePasswordAuthenticationToken getAuthentication(String token) {
List<SimpleGrantedAuthority> authorities = getAuthorities(token);
String userName = getTokenInfo(token).getSubject();
return new UsernamePasswordAuthenticationToken(userName, token, authorities);
}

/**
* 根据 Token 信息获取当前用户所有角色信息
* @param token Token 信息
* @return 用户角色信息集合
*/
private static List<SimpleGrantedAuthority> getAuthorities(String token) {
String role = getTokenInfo(token).getClaim(ROLE_CLAIMS).asString();
return Arrays.stream(role.split(","))
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toList());
}

/**
* 获取 Token 中 Payload
* @param token 信息来源
* @return 相应的 Token 信息
*/
private static DecodedJWT getTokenInfo(String token){
return JWT.require(algorithm).build().verify(token);
}
}

上述工具类中所使用到的响应原数据类 ResponseMetaData

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
/**
* @author mofan
* @date 2021/4/21 16:34
* 与响应相关的元数据
*/
public class ResponseMetaData {

/**
* Token 信息描述
*/
public static final String TOKEN_HEADER = "Authorization";
/**
* Token 信息前缀
*/
public static final String TOKEN_PREFIX = "Bearer";

/**
* 登录用户 ID 描述
*/
public static final String USER_ID = "UID";
/**
* 登录用户账号描述
*/
public static final String USER_ACCOUNT = "User-Account";
/**
* 登录用户角色信息描述
*/
public static final String ROLE_INFO = "Role-Info";


public static final String CONTENT_DISPOSITION = "Content-Disposition";

/**
* <p><strong>需要暴露的响应头</strong></p>
* <p>在默认的请求上,浏览器只能访问以下默认的响应头:</p>
* <ul>
* <li>Cache-Control</li>
* <li>Content-Language</li>
* <li>Content-Type</li>
* <li>Expires</li>
* <li>Last-Modified</li>
* <li>Pragma</li>
* </ul>
* <p>如果想让浏览器访问自定义或其他响应头,需要在响应请求头上设置该值。</p>
* <p>如果有多个需要暴露的响应头,将它们以逗号分割开即可,比如:</p>
* <blockquote><pre>
* response.setHeader("Access-Control-Expose-Headers", "Authorization,UID");
* </pre></blockquote><p>
*/
public static final String ACCESS_CONTROL_EXPOSE_HEADERS = "Access-Control-Expose-Headers";

/**
* 获取需要暴露的响应头
*
* @return 以逗号分割的响应头信息
*/
public static String getExposeHeaders() {
return TOKEN_HEADER + ","
+ USER_ID + ","
+ USER_ACCOUNT + ","
+ ROLE_INFO + ","
+ CONTENT_DISPOSITION;
}

}

4.2 整合 SpringSecurity

整合前的配置

在整合 SpringSecurity 时,需要先对 Redis 和 SpringSecurity 进行配置,因此需要编写两个配置类。

Redis 的配置类:

1
2
3
4
5
6
7
8
9
@Configuration
public class RedisConfig {
@Bean
public RedisTemplate<String, String> redisTemplate(RedisConnectionFactory factory) {
RedisTemplate<String, String> redisTemplate = new RedisTemplate<>();
redisTemplate.setConnectionFactory(factory);
return redisTemplate;
}
}

SpringSecurity 的配置类:

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
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {

private final StringRedisTemplate stringRedisTemplate;

public SecurityConfig(StringRedisTemplate stringRedisTemplate) {
this.stringRedisTemplate = stringRedisTemplate;
}

/**
* 密码编码器
*/
@Bean
public BCryptPasswordEncoder bCryptPasswordEncoder() {
return new BCryptPasswordEncoder();
}

@Override
protected void configure(HttpSecurity http) throws Exception {
http.cors(Customizer.withDefaults())
// 禁用 CSRF
.csrf().disable()
.authorizeRequests()
// OPTIONS 请求全部放行
.antMatchers(HttpMethod.OPTIONS, "/**").permitAll()
// 用户登录接口放行,其他接口全部接受验证
.antMatchers(HttpMethod.POST, "/auth/login").permitAll()
.anyRequest().authenticated()
.and()
//添加自定义 Filter
.addFilter(new JwtAuthorizationFilter(authenticationManager(), stringRedisTemplate))
// 不需要 session(不创建会话)
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS).and()
// 授权异常处理
.exceptionHandling()
.authenticationEntryPoint(new JwtAuthenticationEntryPoint())
.accessDeniedHandler(new JwtAccessDeniedHandler());
}

/**
* Cors 配置
**/
@Bean
public CorsConfigurationSource corsConfigurationSource() {
org.springframework.web.cors.CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOrigins(Collections.singletonList("*"));
configuration.setAllowedHeaders(Collections.singletonList("*"));
configuration.setAllowedMethods(Arrays.asList("GET", "POST", "DELETE", "PUT", "OPTIONS"));
// 已在 Controller 中配置,此处可不进行配置
configuration.setExposedHeaders(Arrays.asList(
ResponseMetaData.TOKEN_HEADER, ResponseMetaData.ROLE_INFO,
ResponseMetaData.USER_ID, ResponseMetaData.USER_ACCOUNT,
ResponseMetaData.CONTENT_DISPOSITION
));
// 是否允许携带cookie,默认情况下,cors不会携带cookie,除非这个值是 true
configuration.setAllowCredentials(false);
configuration.setMaxAge(3600L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration);
return source;
}
}

JWT 认证过滤器类 JwtAuthorizationFilter

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
/**
* @author mofan
* @date 2021/4/22 10:16
* 该过滤器用于处理身份认证后才能访问的资源
* 过滤器处理所有 HTTP 请求,并检查是否存在带有正确令牌的 Authorization 标头。
* 例如,如果令牌未过期或签名密钥正确。
*/
@Slf4j
public class JwtAuthorizationFilter extends BasicAuthenticationFilter {

private final StringRedisTemplate stringRedisTemplate;

public JwtAuthorizationFilter(AuthenticationManager authenticationManager,
StringRedisTemplate stringRedisTemplate) {
super(authenticationManager);
this.stringRedisTemplate = stringRedisTemplate;
}

@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain chain) throws IOException, ServletException {

String authorization = request.getHeader(ResponseMetaData.TOKEN_HEADER);
// 如果请求头中没有 Authorization 信息则直接放行了
if (authorization == null || !authorization.startsWith(ResponseMetaData.TOKEN_PREFIX)) {
SecurityContextHolder.clearContext();
chain.doFilter(request, response);
return;
}
// 如果请求头中有 token,则进行解析,并且设置授权信息
String token = authorization.replace(ResponseMetaData.TOKEN_PREFIX, "").trim();
UsernamePasswordAuthenticationToken authenticationToken = null;
try {
String previousToken = stringRedisTemplate.opsForValue().get(JwtUtil.getId(token));
if (!authorization.equals(previousToken)) {
SecurityContextHolder.clearContext();
chain.doFilter(request, response);
return;
}
authenticationToken = JwtUtil.getAuthentication(token);
} catch (JWTVerificationException e) {
log.error("Token信息校验失败 " + e.getMessage());
SecurityContextHolder.clearContext();
// 返回 JSON 信息至前端
response.setCharacterEncoding("utf-8");
response.setContentType("text/javascript;charset=utf-8");
ObjectMapper mapper = new ObjectMapper();
String resultJson = mapper.writeValueAsString(
Result.error(ErrorCode.JWT_VERIFICATION_ERROR, "用户登录信息失效,请重新登录")
);
response.getWriter().print(resultJson);
// 添加 return 关键字,将错误信息返回给前端
return;
}
SecurityContextHolder.getContext().setAuthentication(authenticationToken);
chain.doFilter(request, response);
}
}

另外两个自定义的类 JwtAccessDeniedHandlerJwtAuthenticationEntryPoint

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/**
* @author mofan
* @date 2021/4/21 16:09
* 用来解决认证过的用户访问需要权限才能访问的资源时的异常,即:用户访问权限不足时抛出的异常
*/
public class JwtAccessDeniedHandler implements AccessDeniedHandler {

@Override
public void handle(HttpServletRequest request,
HttpServletResponse response,
AccessDeniedException accessDeniedException) throws IOException, ServletException {
// 返回 JSON 信息至前端
response.setCharacterEncoding("utf-8");
response.setContentType("text/javascript;charset=utf-8");
ObjectMapper mapper = new ObjectMapper();
String resultJson = mapper.writeValueAsString(
Result.error(ErrorCode.INSUFFICIENT_ACCESS_RIGHTS, "没有访问权限!")
);
response.getWriter().print(resultJson);
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
/**
* @author mofan
* @date 2021/4/21 16:26
* 用来解决匿名用户访问需要权限才能访问的资源时的异常
*/
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {

@Override
public void commence(HttpServletRequest request,
HttpServletResponse response,
AuthenticationException authenticationException) throws IOException, ServletException {
/*
* 当用户尝试访问需要权限才能的 REST 资源而未进行认证时,
* 返回 JSON 信息至前端
*/
response.setCharacterEncoding("utf-8");
response.setContentType("text/javascript;charset=utf-8");
ObjectMapper mapper = new ObjectMapper();
String resultJson = mapper.writeValueAsString(
Result.error(ErrorCode.ANONYMOUS_USER_ACCESS, "请登录后重试")
);
response.getWriter().print(resultJson);
}
}

整合 SpringSecurity

在本次整合结束后,需要完成利用 SpringSecurity + JWT + Redis 完成用户登入与登出(不含用户注册)。

首先需要一个用户服务 UserService 类,用于查询用户、密码校验、获取角色信息等:

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
@Slf4j
@Service
public class UserService {
@Autowired
private UserInfoDao userInfoDao;
@Autowired
private RoleDao roleDao;
@Autowired
private BCryptPasswordEncoder bCryptPasswordEncoder;


public UserInfo findByUserAccount(String userName) {
LambdaQueryWrapper<UserInfo> eq = Wrappers.<UserInfo>lambdaQuery().eq(UserInfo::getUserAccount, userName);
UserInfo userInfo = userInfoDao.selectOne(eq);
if (userInfo == null) {
throw new BusinessException(ErrorCode.USER_NOT_EXISTS).addError("用户信息不存在");
}
return userInfo;
}

public boolean checkPassword(String currentPassword, String password) {
return this.bCryptPasswordEncoder.matches(currentPassword, password);
}

/**
* 根据 userId 获取该用户所有角色信息
* @param userId 用户的 ID
* @return 角色信息列表
*/
public List<Role> getRoleList(Long userId) {
LambdaQueryWrapper<Role> wrapper = Wrappers.<Role>lambdaQuery()
.eq(Role::getUserId, userId)
.select(Role::getRole);
return roleDao.selectList(wrapper);
}
}

然后需要一个 CurrentUser 类,用于获取当前请求用户:

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
/**
* @author mofan
* @date 2021/4/22 10:30
* 获取当前请求用户
*/
@Component
public class CurrentUser {

@Autowired
private UserInfoDao userInfoDao;

/**
* 获取当前用户
* @return 当前用户信息
*/
public UserInfo getCurrentUser() {
return userInfoDao.selectOne(
Wrappers.<UserInfo>lambdaQuery()
.eq(UserInfo::getUserAccount, getCurrentUserName())
);
}

/**
* 当认证成功的用户访问系统的时候,它的认证信息会被设置在 Spring Security 全局中
*/
private String getCurrentUserName() {
// 只需通过这一句即可获取到当前登录用户的授权信息
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication != null && authentication.getPrincipal() != null) {
return (String) authentication.getPrincipal();
}
return null;
}
}

接下来编写授权服务实现类 AuthServiceImpl,该类实现授权服务接口(接口代码省略),内部编写用户登入与登出的逻辑:

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
@Slf4j
@Service
@Transactional(rollbackFor = Exception.class)
public class AuthServiceImpl implements AuthService {

@Autowired
private UserService userService;
@Autowired
private CurrentUser currentUser;
@Autowired
private StringRedisTemplate stringRedisTemplate;

@Override
public Result<LoginResponse> createToken(LoginInfoDto dto) {
UserInfo userInfo = userService.findByUserAccount(dto.getUserAccount());
if (!userService.checkPassword(dto.getUserPwd(), userInfo.getUserPwd())) {
throw new BusinessException(ErrorCode.USER_PASSWORD_ERROR).addError("登录密码错误");
}
Long userInfoId = userInfo.getId();
List<Role> roleList = userService.getRoleList(userInfoId);
List<String> roleInfo;
if (roleList.size() > 0) {
roleInfo = roleList.stream().map(i -> i.getRole().name()).collect(Collectors.toList());
} else {
throw new BusinessException(ErrorCode.USER_ROLE_ERROR).addError("用户认证信息异常");
}
JwtUser jwtUser = new JwtUser(userInfo, roleInfo);
if (!jwtUser.isEnabled()) {
throw new BusinessException(ErrorCode.USER_HAS_BEEN_FROZEN).addError("当前用户已被冻结");
}
String id = userInfoId.toString();
// 生成 Token 信息
String token = JwtUtil.getToken(userInfo.getUserAccount(), id,
roleInfo, dto.isRememberMe());
// 存入 redis 中
stringRedisTemplate.opsForValue().set(id, token);
return Result.success(new LoginResponse(token, userInfoId, userInfo.getUserAccount(), roleInfo));
}

@Override
public void removeToken() {
stringRedisTemplate.delete(currentUser.getCurrentUser().getId().toString());
}
}

最后编写授权控制类 AuthController 即可:

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
/**
* @author mofan
* @date 2021/4/24 11:24
*/
@RestController
@RequestMapping("auth")
public class AuthController {

@Autowired
private AuthService authService;

@PostMapping("login")
public Result<Void> login(@Valid @RequestBody LoginInfoDto loginInfoDto, HttpServletResponse response) {
Result<LoginResponse> result = authService.createToken(loginInfoDto);
LoginResponse loginResponse = result.getData();
response.setHeader(ResponseMetaData.TOKEN_HEADER, loginResponse.getToken());
// 设置 uid 信息,增加拓展性(可根据需要设置)
response.setHeader(ResponseMetaData.USER_ID, loginResponse.getUserId().toString());
response.setHeader(ResponseMetaData.USER_ACCOUNT, loginResponse.getUserAccount());
response.setHeader(ResponseMetaData.ROLE_INFO, String.join(",", loginResponse.getRoleInfo()));
response.setHeader(ResponseMetaData.ACCESS_CONTROL_EXPOSE_HEADERS, ResponseMetaData.getExposeHeaders());
return Result.success();
}

@PostMapping("logout")
public Result<Void> logout() {
authService.removeToken();
return Result.success();
}
}

JWT 的基本使用完