登录、注册学习

1. 引言

在项目中,登录、注册一般都是项目的必备功能,因为它们的用户管理和身份验证的基础,通过登录和注册,能有效保护用户个人数据,并根据用户权限,进行对应的资源访问控制。此外,还能对注册后的用户进行行为分析,以便提供对应的个性化服务。

2. 登录、注册实现

2.1. 项目结构介绍

这里的项目结构,借鉴周志明老师提出的凤凰架构,将项目分为四层:
1)domain: 领域层,负责实现业务逻辑,即表达业务概念、处理业务状态信息以及业务规这些行为,提供对应的领域服务。
2)infrastructure:基础设施层,向其他层提供通用的技术能力,譬如持久化能力、远程访问通信、工具集等。
3)application:应用层,负责软件本身对外暴露的能力,通过整合各个领域服务,进行协助,对外提供服务,相当于各个领域服务的门面,类似于MVC架构中的service层。
4)controller:负责向用户显示信息或解释用户发出的命令,即MVC架构中的controller层。

2.2. 简单实现

2.2.1. 准备工作

首先引入下列依赖:

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
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.4.3</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
</dependency>
</dependencies>

创建对应的数据库,数据库表结构如下:

这里的username添加了唯一索引,因为用户名一般都是唯一的。

2.2.2. domain层

创建对应的实体类,以及相关的repository仓储层和domainservice领域服务

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
@Data
@TableName(value = "t_user")
public class User implements Serializable {
@TableId(type = IdType.AUTO)
private Integer id;

private String username;

private String password;

// 是否冻结 0未冻结 1已冻结
private Integer freeze;

private Date createTime;

private Date updateTime;

private Map<String, String> featuresMap;

private String features;
}



public interface IUserRepository {

boolean saveUser(User user);


User findByUsernameAndPassword(String username, String password);
}



@Repository
public class UserRepository implements IUserRepository {
@Autowired
private UserMapper userMapper;

private static final int UN_FREEZE = 0;
private static final int FREEZE = 1;

@Override
public boolean saveUser(User user) {
user.setCreateTime(new Date());
user.setUpdateTime(new Date());
user.setFreeze(UN_FREEZE);
user.setFeaturesMap(new HashMap<>());
user.setFeatures(JSONObject.toJSONString(user.getFeaturesMap()));
return userMapper.insert(user) > 0;
}

@Override
public User findByUsernameAndPassword(String username, String password) {
LambdaQueryWrapper<User> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(User::getUsername, username);
queryWrapper.eq(User::getPassword, password);
return userMapper.selectOne(queryWrapper);
}


}


@Service
public class UserService {
@Resource
private IUserRepository userRepository;

public User register(RegisterUserRequest request) {
User user = new User();
user.setUsername(request.getUsername());
user.setPassword(request.getPassword());
try {
userRepository.saveUser(user);
return user;
} catch (Exception e) {
e.printStackTrace();
throw new BusinessException(ResultCode.SERVER_ERROR);
}
}

public User login(LoginUserRequest request) {
return userRepository.findByUsernameAndPassword(request.getUsername(), request.getPassword());
}
}
2.2.3. controller层

添加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
@RestController
@RequestMapping(value = "/user")
public class UserController {
@Autowired
private UserService userService;

@PostMapping(value = "/register")
public Response<User> register(@RequestBody RegisterUserRequest request) {
User user = userService.register(request);
return Response.success(user);
}

@PostMapping(value = "/login")
public Response<User> login(@RequestBody LoginUserRequest request) {
User user = userService.login(request);
if (user == null) {
throw new BusinessException(ResultCode.LOGIN_FAILED);
}
return Response.success(user);
}
}



@Data
public class LoginUserRequest implements Serializable {
private String username;

private String password;
}



@Data
public class RegisterUserRequest implements Serializable {
private String username;

private String password;

}
2.2.4. infrastructure层

添加基础设施,比如异常类、返回结果类、错误码,全局异常处理等

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
public enum ResultCode {
SUCCESS(200, "操作成功"),
ACCESS_DENIED(403, "没有权限"),
FAILED(400, "操作失败"),

LOGIN_FAILED(400, "用户不存在或密码错误"),
SERVER_ERROR(500, "服务器错误");

private int code;

private String msg;

ResultCode(int code, String msg) {
this.code = code;
this.msg = msg;
}

public int getCode() {
return this.code;
}

public String getMsg() {
return this.msg;
}
}




public class Response <T> {
private int code;

private String msg;

private T data;

private Response(Integer code, String msg) {
this.code = code;
this.msg = msg;
}

private Response(Integer code, String msg, T data) {
this.code = code;
this.msg = msg;
this.data = data;
}

private Response(ResultCode resultCode) {
this.code = resultCode.getCode();
this.msg = resultCode.getMsg();
}

private Response(ResultCode resultCode, T data) {
this.code = resultCode.getCode();
this.msg = resultCode.getMsg();
this.data = data;
}

public static <T> Response<T> success() {
return new Response<>(ResultCode.SUCCESS);
}

public static <T> Response<T> success(T data) {
return new Response<>(ResultCode.SUCCESS, data);
}

public static <T> Response<T> fail() {
return new Response<>(ResultCode.FAILED);
}

public static <T> Response<T> error() {
return new Response<>(ResultCode.SERVER_ERROR);
}

public static <T> Response<T> fail(ResultCode resultCode) {
return new Response<>(resultCode);
}

public static <T> Response<T> fail(String msg) {
return new Response<>(ResultCode.FAILED.getCode(), msg);
}

public int getCode() {
return this.code;
}

public String getMsg() {
return this.msg;
}

public T getData() {
return this.data;
}
}



public class BusinessException extends RuntimeException {
private ResultCode resultCode;

public BusinessException(ResultCode resultCode) {
super(resultCode.getMsg());
this.resultCode = resultCode;
}

public ResultCode getResultCode() {
return this.resultCode;
}
}



@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(value = BusinessException.class)
public Response handleBusinessException(BusinessException businessException) {
return Response.fail(businessException.getResultCode());
}

@ExceptionHandler(value = Exception.class)
public Response handleException(Exception e) {
return Response.fail(e.getMessage());
}
}
2.2.5. application层

因为现在的功能比较简单,不涉及到多个领域对象的交互,所以这里暂时不添加相关的应用服务。

2.2.6. 测试

所以接口测试工具,分别对注册接口和登录接口进行访问

2.3. 密码加密

上述流程虽然能够跑通,但是存在一个问题,用户密码在数据库中,以明文的方式进行存储,这样不太合理,容易将用户数据泄露出去。因此,这里进行修改。
在用户密码加密中,我们经常会使用到盐(Salt),盐是一种随机值,它与用户密码组合起来,形成一个组合密码,然后使用加密哈希函数(比如SHA-)对组合密码进行加密,生成哈希值,并将加密后的哈希值存储在数据库中,当用户登录时,系统会取出存储的哈希值,通过与用户输入的密码组合,进行哈希加密,然后比对与数据库中存储的值是否一致。

2.3.1. 修改表结构

在之前的表设计中,我们预留了一个features字段,表示扩展信息,我们其实可以将盐存入到扩展信息中,但是因为盐我们在登录中进场使用到,因此,还是单独作为一个字段,修改后的表结构如下:

2.3.2. infrastructure层

在基础设施层中,添加上加密工具类

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

import cn.hutool.crypto.SecureUtil;
import cn.hutool.crypto.digest.Digester;

import java.util.concurrent.ThreadLocalRandom;

public class EncryptUtils {
private static final Digester sha256 = SecureUtil.sha256();

private static final char[] saltChars = "0123456789abcdefghijklmnopqrstuvwxyz".toCharArray();

private static final int SALT_LEN = 6;

/**
* 加密
* @param originPwd
* @param salt
* @return
*/
public static String encrypt(String originPwd, String salt) {
String newPwd = salt + originPwd;
byte[] digest = sha256.digest(newPwd);
return new String(digest);
}

/**
* 生成盐
* @return
*/
public static String generateSalt() {
StringBuilder sb = new StringBuilder();
ThreadLocalRandom threadLocalRandom = ThreadLocalRandom.current();
for (int i = 0; i < SALT_LEN; i++) {
int index = threadLocalRandom.nextInt(saltChars.length);
sb.append(saltChars[index]);
}
return sb.toString();
}
}
2.3.3. domain

修改domain层中的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
37
38
39
40
41
42
43
package com.yang.domain.service;

import com.yang.controller.request.LoginUserRequest;
import com.yang.controller.request.RegisterUserRequest;
import com.yang.domain.data.User;
import com.yang.domain.repository.IUserRepository;
import com.yang.infrastructure.common.ResultCode;
import com.yang.infrastructure.exception.BusinessException;
import com.yang.infrastructure.utils.EncryptUtils;
import org.springframework.stereotype.Service;

import javax.annotation.Resource;

@Service
public class UserService {
@Resource
private IUserRepository userRepository;

public User register(RegisterUserRequest request) {
User user = new User();
user.setUsername(request.getUsername());
// 设置盐
user.setSalt(EncryptUtils.generateSalt());
user.setPassword(EncryptUtils.encrypt(request.getPassword(), user.getSalt()));
try {
userRepository.saveUser(user);
return user;
} catch (Exception e) {
e.printStackTrace();
throw new BusinessException(ResultCode.SERVER_ERROR);
}
}

public User login(LoginUserRequest request) {
User user = userRepository.findByUsername(request.getUsername());
String salt = user.getSalt();
String password = EncryptUtils.encrypt(request.getPassword(), salt);
if (password.equals(user.getPassword())) {
return user;
}
return null;
}
}
2.3.4. 测试

重新运行项目,调用注册接口和登录接口

查看数据库,可以看出此时的password确实是经过加密后生成的。

3. Token生成

3.1. 准备工作

一般情况下,除了登录和注册接口,不需要进行登录拦截之外,其他的接口,都需要对用户的登录状态进行拦截,判断用户是否登录,如若未登录,则提示用户需要进行登录。对此,我们在用户登录成功后,可以返回一个token作为登录凭证返回给前端。

这里我们在构建token的时候,使用JWT来构建,JWT(JSON Web Token)是为了在网络应用环境间传递声明而执行的一种基于JSON的开放标准。JWT主要由三部分组成:
1)头部(Header),通常包含两部分信息,alg(alogirthm,指定用于签名或加密令牌的算法),typ(类型,表明令牌的类型为JWT)

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

2)有效载荷(Payload),一个json对象,包含一系列声明,比如sub(subject主题,通常是用户id),name(用户名称),exp(过期时间)等。
3)签名:对前两部分的串行化后的字符串使用指定的算法(如SHA256或RSA签名)生成的一个加密串,它的作用是保证令牌的完整性和真实性,保证传输过程中没有被修改。
这三个部分之间用.分隔,构成JWT的完整结构,如:

1
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7H8RU

为整合JWT,我们引入下列依赖:

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

3.2. infrastructure层

在基础设施层,我们添加和鉴权相关的信息,首先添加一个jwt配置类,用于配置jwt使用的密钥和过期时间

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
package com.yang.infrastructure.auth.config;

import lombok.Data;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;

@Component
@Data
public class JwtTokenProperty {

@Value("${jwt.token.secret}")
private String secret;

@Value("${jwt.token.expire}")
private Integer expire;
}

定义一个JwtTokenService接口,以及对应的实现类:

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 interface JwtTokenService {
String generateJwtToken(JwtTokenGenerateRequest request);

JwtTokenVerifyDTO verify(JwtTokenVerifyRequest request);

}



@Component
public class JwtTokenServiceImpl implements JwtTokenService{
private final Integer EXPIRE_TIME = 604800;

private final String SECRET = "helloworld";

@Override
public String generateJwtToken(JwtTokenGenerateRequest request) {
JWTCreator.Builder builder = JWT.create();
if (MapUtil.isNotEmpty(request.getPayLoads())) {
request.getPayLoads().forEach((k, v) -> {
builder.withClaim(k, v);
});
}

Calendar expireTime = Calendar.getInstance();
expireTime.add(Calendar.SECOND, request.getExpireTime() != null ? request.getExpireTime() : EXPIRE_TIME);
return builder.withSubject(request.getSubject())
.withIssuedAt(new Date())
.withExpiresAt(expireTime.getTime())
.sign(Algorithm.HMAC256(StringUtils.isNotEmpty(request.getSecret()) ? request.getSecret() : SECRET));
}

@Override
public JwtTokenVerifyDTO verify(JwtTokenVerifyRequest request) {
JWTVerifier jwtVerifier = JWT.require
(Algorithm.HMAC256(StringUtils.isNotEmpty(request.getSecret()) ? request.getSecret() : SECRET))
.build();
DecodedJWT decodedJWT = null;
try {
decodedJWT = jwtVerifier.verify(request.getToken());
} catch (Exception e) {
throw new BusinessException(ResultCode.TOKEN_FAILED);
}

JwtTokenVerifyDTO jwtTokenVerifyDTO = new JwtTokenVerifyDTO();
Map<String, Claim> claims = decodedJWT.getClaims();
if (MapUtil.isNotEmpty(claims)) {
claims.forEach((k, v) -> {
jwtTokenVerifyDTO.getPayLoads().put(k, v.asString());
});
}
jwtTokenVerifyDTO.setSubject(decodedJWT.getSubject());
jwtTokenVerifyDTO.setExpireTime(decodedJWT.getExpiresAt());
return jwtTokenVerifyDTO;
}
}

3.3. application层

因为现在登录涉及到token等操作,对于token的生成, 这个不属于userService领域服务范围,因此就涉及到多个服务之间的协作,所以此时使用applicationService来整合多个服务。我们添加一个UserApplicationService类

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
package com.yang.application.service;

import com.yang.application.dto.UserLoginDTO;
import com.yang.controller.request.LoginUserRequest;
import com.yang.controller.request.RegisterUserRequest;
import com.yang.domain.data.User;
import com.yang.domain.service.UserService;
import com.yang.infrastructure.auth.config.JwtTokenProperty;
import com.yang.infrastructure.auth.request.JwtTokenGenerateRequest;
import com.yang.infrastructure.auth.service.JwtTokenService;
import com.yang.infrastructure.common.ResultCode;
import com.yang.infrastructure.exception.BusinessException;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import javax.annotation.Resource;
import java.util.HashMap;
import java.util.Map;

@Service
public class UserApplicationService {
@Autowired
private UserService userService;

@Autowired
private JwtTokenProperty jwtTokenProperty;

@Resource
private JwtTokenService jwtTokenService;

public User register(RegisterUserRequest request) {
return userService.register(request);
}

public UserLoginDTO login(LoginUserRequest request) {
User user = userService.login(request);
if (user == null) {
throw new BusinessException(ResultCode.LOGIN_FAILED);
}
// 生成token
UserLoginDTO userLoginDTO = new UserLoginDTO();
userLoginDTO.setUser(user);

JwtTokenGenerateRequest jwtGenerateRequest = convert2JwtTokenGenerateRequest(user);
userLoginDTO.setToken(jwtTokenService.generateJwtToken(jwtGenerateRequest));
return userLoginDTO;
}

private JwtTokenGenerateRequest convert2JwtTokenGenerateRequest(User user) {
JwtTokenGenerateRequest request = new JwtTokenGenerateRequest();
request.setSubject(user.getId().toString());
request.setExpireTime(jwtTokenProperty.getExpire());
request.setSecret(jwtTokenProperty.getSecret());

Map<String, String> payloads = new HashMap<>();
payloads.put("username", user.getUsername());
payloads.put("id", user.getId().toString());
payloads.put("salt", user.getSalt());
request.setPayLoads(payloads);

return request;
}
}

3.4. controller层

我们修改controller层,改用applicationService,并添加一个接口,来测试我们的JwtTokenService解析是否正确

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
package com.yang.controller;

import com.yang.application.service.UserApplicationService;
import com.yang.application.dto.UserLoginDTO;
import com.yang.controller.request.LoginUserRequest;
import com.yang.controller.request.RegisterUserRequest;
import com.yang.domain.data.User;
import com.yang.infrastructure.auth.config.JwtTokenProperty;
import com.yang.infrastructure.auth.request.JwtTokenVerifyRequest;
import com.yang.infrastructure.auth.response.JwtTokenVerifyDTO;
import com.yang.infrastructure.auth.service.JwtTokenService;
import com.yang.infrastructure.common.Response;
import com.yang.infrastructure.common.ResultCode;
import com.yang.infrastructure.exception.BusinessException;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping(value = "/user")
public class UserController {
@Autowired
private UserApplicationService userApplicationService;

@PostMapping(value = "/register")
public Response<User> register(@RequestBody RegisterUserRequest request) {
User user = userApplicationService.register(request);
return Response.success(user);
}

@PostMapping(value = "/login")
public Response<UserLoginDTO> login(@RequestBody LoginUserRequest request) {
UserLoginDTO userLoginDTO = userApplicationService.login(request);
if (userLoginDTO == null) {
throw new BusinessException(ResultCode.LOGIN_FAILED);
}
return Response.success(userLoginDTO);
}

@Autowired
private JwtTokenService jwtTokenService;

@Autowired
private JwtTokenProperty jwtTokenProperty;

@GetMapping(value = "/verify")
public Response<JwtTokenVerifyDTO> verify(@RequestParam(name = "token")String token) {
JwtTokenVerifyRequest request = new JwtTokenVerifyRequest();
request.setToken(token);
request.setSecret(jwtTokenProperty.getSecret());
JwtTokenVerifyDTO verify = jwtTokenService.verify(request);
return Response.success(verify);
}
}

3.5. 测试

首先调用登录接口,进行测试

将登录接口返回的token复制,调用verfiy接口,解析成功,说明JwtTokenService没问题

然后我们随意捏造一个token进行访问,结果如下:

4. 登录拦截

后端返回token 给前端后,前端保存这个token,在后续发送请求时,将这个token携带到请求头进行访问,后端解析请求头,解析该token,判断token是否生效,当token有效时,对请求进行放行。也就是说,我们在执行业务代码之前,都会先对请求进行拦截,并校验token的合法性。因此,就需要使用到拦截器。

4.1. infrastructure层

在基础设施层,添加和登录拦截有关的类,首先添加一个Spring上下文工具类,帮助获取容器中的bean

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
package com.yang.infrastructure.utils;

import org.springframework.beans.BeansException;
import org.springframework.context.ApplicationContext;
import org.springframework.context.ApplicationContextAware;
import org.springframework.stereotype.Component;

@Component
public class SpringContextUtils<T> implements ApplicationContextAware {
private static ApplicationContext applicationContext;
@Override
public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
SpringContextUtils.applicationContext = applicationContext;
}

public static<T> T getBeanOfType(Class<T> clazz) {
return applicationContext.getBean(clazz);
}

public static <T> T getBeanOfName(String name, Class<T> clazz) {
return applicationContext.getBean(name, clazz);
}
}

然后添加一个JwtTokenVerifyInterceptor类,实现HandlerInterceptor,表示判断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
package com.yang.infrastructure.auth.interceptors;

import com.yang.infrastructure.auth.config.JwtTokenProperty;
import com.yang.infrastructure.auth.request.JwtTokenVerifyRequest;
import com.yang.infrastructure.auth.response.JwtTokenVerifyDTO;
import com.yang.infrastructure.auth.service.JwtTokenService;
import com.yang.infrastructure.common.ResultCode;
import com.yang.infrastructure.exception.BusinessException;
import com.yang.infrastructure.utils.SpringContextUtils;
import org.apache.commons.lang3.StringUtils;
import org.springframework.web.servlet.HandlerInterceptor;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

public class JwtTokenVerifyInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
String token = request.getHeader("token");
if (StringUtils.isEmpty(token)) {
throw new BusinessException(ResultCode.TOKEN_FAILED);
}
JwtTokenService jwtTokenService = SpringContextUtils.getBeanOfType(JwtTokenService.class);
JwtTokenProperty jwtTokenProperty = SpringContextUtils.getBeanOfType(JwtTokenProperty.class);

JwtTokenVerifyRequest jwtTokenVerifyRequest = new JwtTokenVerifyRequest();
jwtTokenVerifyRequest.setToken(token);
jwtTokenVerifyRequest.setSecret(jwtTokenProperty.getSecret());

JwtTokenVerifyDTO verify = jwtTokenService.verify(jwtTokenVerifyRequest);
if (verify == null) {
throw new BusinessException(ResultCode.TOKEN_FAILED);
}
return true;
}
}

然后添加一个配置类,配置刚才的拦截器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
package com.yang.infrastructure.configuration;

import com.yang.infrastructure.auth.interceptors.JwtTokenVerifyInterceptor;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@Configuration
public class WebMvcConfiguration implements WebMvcConfigurer {

@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new JwtTokenVerifyInterceptor())
.addPathPatterns("/**") // 拦截所有请求
.excludePathPatterns("/user/login", "/user/register"); // 排除登录、注册接口
}
}

4.2. 测试

首先,我们在请求头,不添加token,访问测试接口,结果如下:

然后在请求头,添加无效的token,访问测试接口,结果如下:

携带登录返回的token,访问测试接口,结果如下:

5. token存储

5.1. 准备工作

上述实现,虽然能够完成登录拦截的需求,但是有一个问题,我们每次访问接口,都需要对token进行验证,判断这个token是否有效,为减少解析token的耗时,我们可以将token存起来并设置一个过期时间,这个时候,我们就可以使用reids了。此时,我们的调用流程,如下图所示:

因此,我们添加redis的相关依赖:

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

5.2. infrastructure层

在基础设施层,添加上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
@Component
public class RedisConfiguration {

@Bean
public RedisTemplate redisTemplate(RedisConnectionFactory redisConnectionFactory) {
RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
redisTemplate.setConnectionFactory(redisConnectionFactory);

StringRedisSerializer stringRedisSerializer = new StringRedisSerializer();
Jackson2JsonRedisSerializer jackson2JsonRedisSerializer = new Jackson2JsonRedisSerializer(Object.class);
ObjectMapper objectMapper = new ObjectMapper().enableDefaultTyping(ObjectMapper.DefaultTyping.NON_FINAL);
jackson2JsonRedisSerializer.setObjectMapper(objectMapper);

redisTemplate.setKeySerializer(stringRedisSerializer);
redisTemplate.setHashKeySerializer(stringRedisSerializer);
redisTemplate.setValueSerializer(jackson2JsonRedisSerializer);
redisTemplate.setHashValueSerializer(jackson2JsonRedisSerializer);
return redisTemplate;
}
}



@Component
public class RedisUtils {
@Autowired
private RedisTemplate<String, Object> redisTemplate;

public void setKey(String key, Object value) {
redisTemplate.opsForValue().set(key, value);
}

public void setKey(String key, Object value, Long expire) {
setKey(key, value, expire, TimeUnit.SECONDS);
}

public void setKey(String key, Object value, Long expire, TimeUnit timeUnit) {
redisTemplate.opsForValue().set(key, value, expire, timeUnit);
}

public Object getKey(String key) {
return redisTemplate.opsForValue().get(key);
}
}

然后修改JwtTokenVerifyInterceptor拦截器,先查询redis上是否存在对应的token,有的话放行,否则使用JwtTokenService验证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
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
String token = request.getHeader("token");
if (StringUtils.isEmpty(token)) {
throw new BusinessException(ResultCode.TOKEN_FAILED);
}
if(verifyByRedis(token)) { // 判断该token在Redis是否存在
System.out.println("Redis中存在这个token,放行");
return true;
}
System.out.println("Redis中不存在这个token,解析该token");
JwtTokenService jwtTokenService = SpringContextUtils.getBeanOfType(JwtTokenService.class);
JwtTokenProperty jwtTokenProperty = SpringContextUtils.getBeanOfType(JwtTokenProperty.class);

JwtTokenVerifyRequest jwtTokenVerifyRequest = new JwtTokenVerifyRequest();
jwtTokenVerifyRequest.setToken(token);
jwtTokenVerifyRequest.setSecret(jwtTokenProperty.getSecret());

JwtTokenVerifyDTO verify = jwtTokenService.verify(jwtTokenVerifyRequest);
if (verify == null) {
throw new BusinessException(ResultCode.TOKEN_FAILED);
}
return true;
}

private boolean verifyByRedis(String token) {
RedisUtils redisUtils = SpringContextUtils.getBeanOfType(RedisUtils.class);
Object key = redisUtils.getKey("token:" + token);
return key != null;
}

5.3. application层

修改登录逻辑中,在生成token后,将token存入redis

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public UserLoginDTO login(LoginUserRequest request) {
User user = userService.login(request);
if (user == null) {
throw new BusinessException(ResultCode.LOGIN_FAILED);
}
// 生成token
UserLoginDTO userLoginDTO = new UserLoginDTO();
userLoginDTO.setUser(user);

JwtTokenGenerateRequest jwtGenerateRequest = convert2JwtTokenGenerateRequest(user);
String token = jwtTokenService.generateJwtToken(jwtGenerateRequest);
userLoginDTO.setToken(token);

// token存储到redis
redisUtils.setKey("token:" + token, jwtGenerateRequest, jwtGenerateRequest.getExpireTime());
return userLoginDTO;
}

5.4. 测试

首先访问登录接口,登录成功后,查看redis中是否含有对应的token

redis中存在对应的token,然后我们访问测试接口,查看命令行,此时redis中存在该token,因此直接放行

然后我们删除redis中的这个key,再次访问测试接口,此时redis不存在这个key,因此就需要进行token的解析。

6. 用户信息更新

用户信息,一般情况下更新的频率比较低,但也不是没有,常见的更新有:用户修改密码、用户修改昵称等。我们以用户修改密码为例,进行示例。
当用户修改密码时,我们只需要使用数据库中的salt,结合用户输入的新密码,生成新的哈希值,存入数据库,此外,因为用户信息更新了,我们最好将用户的token生效,存入redis的token,我们可以很容易地将其删除,但是,前端保存地token信息,我们修改不了,因此,最好是前端在更新操作成功后,主动删除请求头的token,从而时用户再次操作时,提示token失效,进行登录。

6.1. infrastructure层

首先,修改RedisUtils工具类,加上删除key的方法

1
2
3
public boolean removeKey(String key) {
return redisTemplate.delete(key);
}

6.2. domain层

修改UserService,添加修改密码的相关操作

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public void updatePassword(UpdatePasswordRequest request) {
Integer userId = request.getId();
User user = userRepository.findById(userId);
if (user == null) {
return;
}
String password = request.getPassword();
String newPassword = EncryptUtils.encrypt(password, user.getSalt());
user.setPassword(newPassword);

try {
userRepository.updateUser(user);
} catch (Exception e) {
throw new BusinessException(ResultCode.SERVER_ERROR);
}
}

6.3. application层

在UserApplicationService类,添加修改用户密码的方法

1
2
3
4
5
6
7
public void updatePassword(UpdatePasswordRequest updatePasswordRequest, HttpServletRequest request) {
userService.updatePassword(updatePasswordRequest);

// 删除token
String token = request.getHeader("token");
redisUtils.removeKey("token:" + token);
}

6.4. controller层

在UserController类,添加修改用户密码的方法

1
2
3
4
5
@PutMapping(value = "/updatePassword")
public Response updatePassword(@RequestBody UpdatePasswordRequest request, HttpServletRequest httpServletRequest) {
userApplicationService.updatePassword(request, httpServletRequest);
return Response.success();
}

6.5. 测试

首先进行登录,登录成功后,在redis中多了一个token

然后调用更新密码接口

再次查看redis,发现token被删除了,然后查看数据库,能看出我们的密码也改变了

7. 用户上下文

上面的代码,看似合理,但是有一个问题,更新密码的时候,传了两个值,一个是用户id,另一个才是用户输入的密码。在之前我们提到,我们通过登录拦截,规避了未登录用户操作系统资源的问题,但是对于我们刚才实现的修改密码接口,可能出现这种情况,用户A携带自己登录的token,请求体中id为用户B的id,调用修改密码的接口,这就导致,用户A修改了用户B的密码,这是不合理的,因此,这里将对代码进一步做修改,在进行拦截操作后,将用户信息,存储于用户上下文,然后在修改密码时,直接使用用户上下文中的用户id,而不是依靠前端传递的id。

7.1. infrastructure层

首先,我们定义一个用户上下文信息类,用于存储用户的主要信息,包括用户id,token,用户名等。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
package com.yang.infrastructure.auth;

import lombok.Data;

import java.io.Serializable;
import java.util.HashMap;
import java.util.Map;

@Data
public class UserContextDetails implements Serializable {
private Integer id;

private String token;

private String username;

private Map<String, String> extendMap = new HashMap<>();
}

添加一个线程上下文类,用于存储用户上下文信息

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
package com.yang.infrastructure.auth;

public class UserContextThreadLocal {
private static ThreadLocal<UserContextDetails> userContextDetailsThreadLocal = new ThreadLocal<>();

public static void setUserContextDetails(UserContextDetails userContextDetails) {
userContextDetailsThreadLocal.set(userContextDetails);
}

public static UserContextDetails get() {
return userContextDetailsThreadLocal.get();
}

public static void remove() {
userContextDetailsThreadLocal.remove();
}

public static Integer getUserId() {
return userContextDetailsThreadLocal.get().getId();
}

public static String getToken() {
return userContextDetailsThreadLocal.get().getToken();
}
}

然后,修改我们的JwtTokenVerifyInterceptor拦截器,这里我们实现了afterCompletion。在preHandler方法中,设置对应的线程上下文,在afterCompletion清除线程上下文,注意,设置线程上下文和清除线程上下文的操作,必须成对出现,否则会造成内存泄露。

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
package com.yang.infrastructure.auth.interceptors;

import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONObject;
import com.yang.infrastructure.auth.UserContextDetails;
import com.yang.infrastructure.auth.UserContextThreadLocal;
import com.yang.infrastructure.auth.config.JwtTokenProperty;
import com.yang.infrastructure.auth.request.JwtTokenVerifyRequest;
import com.yang.infrastructure.auth.response.JwtTokenVerifyDTO;
import com.yang.infrastructure.auth.service.JwtTokenService;
import com.yang.infrastructure.common.ResultCode;
import com.yang.infrastructure.exception.BusinessException;
import com.yang.infrastructure.utils.RedisUtils;
import com.yang.infrastructure.utils.SpringContextUtils;
import org.apache.commons.lang3.StringUtils;
import org.springframework.lang.Nullable;
import org.springframework.web.servlet.HandlerInterceptor;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

public class JwtTokenVerifyInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
String token = request.getHeader("token");
if (StringUtils.isEmpty(token)) {
throw new BusinessException(ResultCode.TOKEN_FAILED);
}
Object userDetails = getUserDetailsFromRedis(token);
if (userDetails != null) { // 判断该token在Redis是否存在
// 设置线程上下文
System.out.println("设置线程上下文====================");
UserContextDetails userContextDetails = (UserContextDetails) userDetails;
userContextDetails.setToken(token);
UserContextThreadLocal.setUserContextDetails(userContextDetails);
return true;
}

JwtTokenService jwtTokenService = SpringContextUtils.getBeanOfType(JwtTokenService.class);
JwtTokenProperty jwtTokenProperty = SpringContextUtils.getBeanOfType(JwtTokenProperty.class);

JwtTokenVerifyRequest jwtTokenVerifyRequest = new JwtTokenVerifyRequest();
jwtTokenVerifyRequest.setToken(token);
jwtTokenVerifyRequest.setSecret(jwtTokenProperty.getSecret());

JwtTokenVerifyDTO verify = jwtTokenService.verify(jwtTokenVerifyRequest);
if (verify == null) {
throw new BusinessException(ResultCode.TOKEN_FAILED);
}

// 设置线程上下文
System.out.println("设置线程上下文====================");
UserContextDetails userContextDetails = new UserContextDetails();
userContextDetails.setId(Integer.valueOf(verify.getSubject()));
userContextDetails.setToken(token);
userContextDetails.setUsername(verify.getPayLoads().get("username"));
userContextDetails.setExtendMap(verify.getPayLoads());
UserContextThreadLocal.setUserContextDetails(userContextDetails);
return true;
}

private Object getUserDetailsFromRedis(String token) {
RedisUtils redisUtils = SpringContextUtils.getBeanOfType(RedisUtils.class);
return redisUtils.getKey("token:" + token);
}

@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, @Nullable Exception ex) throws Exception {
System.out.println("清除线程上下文=======================");
// 清除线程上下文
UserContextThreadLocal.remove();
}
}

7.2. domain层

修改UserService的updatePassword方法,userId从线程上下文获取

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public void updatePassword(UpdatePasswordRequest request) {
Integer userId = UserContextThreadLocal.getUserId();
User user = userRepository.findById(userId);
if (user == null) {
return;
}
String password = request.getPassword();
String newPassword = EncryptUtils.encrypt(password, user.getSalt());
user.setPassword(newPassword);

try {
userRepository.updateUser(user);
} catch (Exception e) {
throw new BusinessException(ResultCode.SERVER_ERROR);
}
}

7.3. application层

修改application层updatePassword方法,token从线程上下文获取

1
2
3
4
5
6
7
public void updatePassword(UpdatePasswordRequest updatePasswordRequest) {
userService.updatePassword(updatePasswordRequest);

// 删除token
String token = UserContextThreadLocal.getToken();
redisUtils.removeKey("token:" + token);
}

7.4. controller层

修改controller层的updatePassword方法

1
2
3
4
5
@PutMapping(value = "/updatePassword")
public Response updatePassword(@RequestBody UpdatePasswordRequest request) {
userApplicationService.updatePassword(request);
return Response.success();
}

7.5. 测试

调用用户登录接口,然后查看redis,可以看出,现在存储的value类型,是UserContextDetails类型

调用测试接口,然后查看控制台

然后查看控制台,从控制台中可以看出,设置线程上下文和清除线程上下文成对出现。

测试修改密码接口

修改成功,说明线程上下文的取值没有问题。

8. 参考文档

https://segmentfault.com/a/1190000040003653


登录、注册学习
https://cxydhi.github.io/2024/04/03/登录、注册学习/
作者
沉河不浮
发布于
2024年4月3日
许可协议