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; 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 ; public static String encrypt (String originPwd, String salt) { String newPwd = salt + originPwd; byte [] digest = sha256.digest(newPwd); return new String (digest); } 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 eyJhbGciOiJIUzI1 NiIsInR5 cCI6 IkpXVCJ9 .eyJzdWIiOiIxMjM0 NTY3 ODkwIiwibmFtZSI6 IkpvaG4 gRG9 lIiwiaWF0 IjoxNTE2 MjM5 MDIyfQ.TJVA95 Or M7 E2 cBab30 RMHrHDcEfxjoYZgeFONFh7 H8 RU
为整合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); } 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)) { 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); } UserLoginDTO userLoginDTO = new UserLoginDTO (); userLoginDTO.setUser(user); JwtTokenGenerateRequest jwtGenerateRequest = convert2JwtTokenGenerateRequest(user); String token = jwtTokenService.generateJwtToken(jwtGenerateRequest); userLoginDTO.setToken(token); 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); 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 ) { 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); 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