接上篇《毕设利器,教你从零搭建一个有规范的spring boot项目【四】——参数校验》
用户身份信息验证这个问题老生常谈了。
用户登录了之后,很多接口都是要做用户信息验证的,一是让服务器知道这个用户是谁,二是出于数据安全考虑。
比如你想看你自己有多少个好友,分别都是谁,那你就要在好友列表的接口带上你的身份信息,不然接口鬼知道你是谁啊,要怎么给你返回数据呢?
学javaweb的时候通常是用session和cookie解决这个问题的,在这里推荐用token。
这也是现在比较流行的方式。
token是一段很长的字符串,有一定的时效性,过期了就没用了,通常我们就是用用户id加密生成,具体的可以看阮一峰老师的《JSON Web Token 入门教程》
阮老师的文章通常都写的很简单易懂,建议好好看看,这里就不再赘述了。
我们会在用户登录的时候返回token,然后在一些需要用户登录之后才能请求的接口,在访问这些接口的时候,在请求头带上token。
比如一个APP的首页,首页的数据很多都是不需要用户登录都能显示的,这时候接口需要不带token也能请求到。
但如果是查看我的好友列表,这些数据很明显需要让服务器知道你是哪个用户,这个时候请求就需要带上token了,如果没token,或者token失效,就让前端同学跳到登陆页面,要求用户登录。
代码的实现方式首先我们需要一个将用户id生成token的工具类。
你也可以用别的,但考虑到这是识别用户用的,大家通常都用用户id来生成token。
引入下面的依赖:
io.jsonwebtoken jjwt 0.9.1 com.auth0 java-jwt 3.4.0
在core包下新建一个jwt包,在里面自己编写一个工具类,用用户id生成token,我也是拿来直接用,这部分可以直接粘贴:
package com.TandK.turntable.core.jwt;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.JwtBuilder;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import org.springframework.stereotype.Component;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
@Component
public class JwtUtil {
//生成签名的时候使用的秘钥secret,这个方法本地封装了的,一般可以从本地配置文件中读取,切记这个秘钥不能外露哦。它就是你服务端的私钥,在任何场景都不应该流露出去。一旦客户端得知这个secret, 那就意味着客户端是可以自我签发jwt了。
// //@Value("${jwt.key}")
private static String key = "sdf23dfgkddfasdasdfghjklzxcvbhtrewq";
public static String createJWT(long ttlMillis, String userUuid) {
//指定签名的时候使用的签名算法,也就是header那部分,jwt已经将这部分内容封装好了。
SignatureAlgorithm signatureAlgorithm = SignatureAlgorithm.HS256;
//生成JWT的时间
long nowMillis = System.currentTimeMillis();
Date now = new Date(nowMillis);
//创建payload的私有声明(根据特定的业务需要添加,如果要拿这个做验证,一般是需要和jwt的接收方提前沟通好验证方式的)
Map claims = new HashMap();
claims.put("userUuid", userUuid);
//生成签发人 一般是公司名字,可以是中文
String subject = "TandK";
//下面就是在为payload添加各种标准声明和私有声明了
//这里其实就是new一个JwtBuilder,设置jwt的body
JwtBuilder builder = Jwts.builder()
//如果有私有声明,一定要先设置这个自己创建的私有的声明,这个是给builder的claim赋值,一旦写在标准的声明赋值之后,就是覆盖了那些标准的声明的
.setClaims(claims)
//设置jti(JWT ID):是JWT的唯一标识,根据业务需要,这个可以设置为一个不重复的值,主要用来作为一次性token,从而回避重放攻击。
.setId(UUID.randomUUID().toString())
//iat: jwt的签发时间
.setIssuedAt(now)
//代表这个JWT的主体,即它的所有人,这个是一个json格式的字符串,可以存放什么userid,roldid之类的,作为什么用户的唯一标志。
.setSubject(subject)
//设置签名使用的签名算法和签名使用的秘钥
.signWith(signatureAlgorithm, key);
if (ttlMillis >= 0) {
long expMillis = nowMillis + ttlMillis;
Date exp = new Date(expMillis);
//设置过期时间
builder.setExpiration(exp);
}
return builder.compact();
}
public static String createJWTBySecond(long seconds, String userUuid) {
return createJWT(seconds * 1000, userUuid);
}
public Claims parseJWT(String token) {
//得到DefaultJwtParser
Claims claims = Jwts.parser()
//设置签名的秘钥
.setSigningKey(key)
//设置需要解析的jwt
.parseClaimsJws(token).getBody();
return claims;
}
public Boolean isVerify(String token, String userUuid) {
//得到DefaultJwtParser
Claims claims = Jwts.parser()
//设置签名的秘钥
.setSigningKey(key)
//设置需要解析的jwt
.parseClaimsJws(token).getBody();
if (claims.get("userUuid").equals(userUuid)) {
return true;
}
return false;
}
}
有了token的生成器后,我们可以建一张表存储用户的token,这样不用在每次请求的时候都去解析用户的token,解析出用户的id,再拿用户id去数据库查出用户的信息。
直接联表查询,查一遍完事,查出用户的token,如果token还没失效,还能顺便把用户信息查出来。
SET NAMES utf8mb4; SET FOREIGN_KEY_CHECKS = 0; -- ---------------------------- -- Table structure for user_token -- ---------------------------- DROp TABLE IF EXISTS `user_token`; CREATE TABLE `user_token` ( `uuid` bigint(19) NOT NULL, `user_uuid` bigint(19) NULL DEFAULT NULL, `access_token` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL, `expire_time` datetime(0) NULL DEFAULT NULL COMMENT '过期时间', `create_time` datetime(0) NULL DEFAULT CURRENT_TIMESTAMP(0), `update_time` datetime(0) NULL DEFAULT CURRENT_TIMESTAMP(0) ON UPDATE CURRENT_TIMESTAMP(0), PRIMARY KEY (`uuid`) USING BTREE ) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci ROW_FORMAT = Dynamic; SET FOREIGN_KEY_CHECKS = 1;
结构如下,除了基础的数据外,user_uuid用来联合user表查出用户信息,access_token存的是token的明文,expire_time存的是token的过期时间。
当用户带着token访问我们的接口时,只需要用下面的sql就可以查出来token是否有效,如果有效还能顺带把用户信息查出来:
SELECT
u.*
FROM
user u
LEFT JOIN user_token ut ON u.uuid = ut.user_uuid
WHERe
ut.access_token = #{token}
AND expire_time >= NOW()
AND u.is_delete = 0
建好表之后顺带把UserTokenPO、对应的mapper和service建一建。
顺带粘一下代码:
PO:
package com.TandK.model.po;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.io.Serializable;
import java.util.Date;
@TableName(value ="user_token")
@Data
public class UserTokenPO implements Serializable {
@TableId(value = "uuid")
private Long uuid;
@TableField(value = "user_uuid")
private Long userUuid;
@TableField(value = "access_token")
private String accessToken;
@TableField(value = "expire_time")
private Date expireTime;
@TableField(value = "create_time")
private Date createTime;
@TableField(value = "update_time")
private Date updateTime;
@TableField(exist = false)
private static final long serialVersionUID = 1L;
}
Mapper:
package com.TandK.mapper; import com.TandK.model.po.UserTokenPO; import com.baomidou.mybatisplus.core.mapper.baseMapper; public interface UserTokenMapper extends baseMapper{ }
Service接口:
package com.TandK.service;
public interface UserTokenService {
}
Service实现类:
package com.TandK.service.impl;
import com.TandK.service.UserTokenService;
import org.springframework.stereotype.Service;
@Service
public class UserTokenServiceImpl implements UserTokenService {
}
有了token的生成器和对应的数据库表后,接下来我们要考虑一个问题。
什么时候校验token?
是的,什么时候校验token呢,前面也说过了,有些接口是不需要token的,而更多时候是需要拿到token解析出用户信息的,难道在每个接口都检查一遍吗?
这样也可以,不过写起来很费解就是了。
重复的东西要写好多遍。
Spring Boot提供了一个叫拦截器的东西,通过这个拦截器,我们可以在每个请求的过程中,在进入到controller之前,拦截下这个请求。
先检查它访问的这个接口需要带token吗。
-
如果不需要,就直接放行。
-
如果需要,就检查是否有带token。
- 如果没带,就提示鉴权失败,让用户登录。
- 如果有带,就检查是否过期。
- 如果没过期,就拿到用户信息并放行。
- 如果过期了,就提示鉴权失败,需要重新登录。
流程是这么个流程。
在建立这个拦截器之前,我们可以先写一个@IgnoreToken注解,这个注解会用在Controller层的方法上。
进入拦截器之后,如果检查到对应的方法有这个注解,就说明不需要校验请求头是否有token,如果没带,才需要校验token。
在core包下建一个annotation包,我们项目里所有的注解都可以写在这。
package com.TandK.turntable.core.annotation;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface IgnoreToken {
}
接下来编写拦截器,在core包下新建一个interceptor包,项目里所有的拦截器都可以放在这个包里。
package com.TandK.core.interceptor;
import org.springframework.web.servlet.HandlerInterceptor;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
public class AuthInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
return false;
}
}
说明一下这个拦截器吧,spring boot里的拦截器需要实现HandlerInterceptor接口,在我们这里使用先关注preHandle这个方法即可,可以看到这个方法返回的是boolean类型,如果是ture,就是放行,让请求能够进入controller层,如果是false,就是被拦截下来,并且不会返回任何数据。
在我们这里,拦截下来了也要告诉前端,是鉴权失败了,这样前端才好做处理。
因此,如果鉴权失败了,我们直接抛前面定义过的业务异常即可,所以在这里需要定义一个鉴权失败的枚举。
细节在前面的《毕设利器,教你从零搭建一个有规范的spring boot项目【三】—— 返回结果的处理和统一异常处理》里说过,如果有不清楚的可以去看看。
这里直接贴对应的枚举:
UNAUTHORIZED(401, "鉴权失败", "鉴权失败");
接下来回来对拦截器继续编写,首先检查访问的controller层方法是否带有@IgnoreToken注解,如果有,直接放行:
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
HandlerMethod handlerMethod = (HandlerMethod) handler;
Method method = handlerMethod.getMethod();
// 判断有没有IgnoreToken注解,如果有,直接放行
if(method.isAnnotationPresent(IgnoreToken.class)){
return true;
}
return true;
}
如果没有,就需要校验token是否有效,先获取请求头带的token。
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
HandlerMethod handlerMethod = (HandlerMethod) handler;
Method method = handlerMethod.getMethod();
// 判断有没有IgnoreToken注解,如果有,直接放行
if(method.isAnnotationPresent(IgnoreToken.class)){
return true;
}
// 先获取前端传来的token,这里的Authrization是小程序推荐的叫法,具体要和前端传来的变量名一致
String token = request.getHeader("Authrization");
if(StringUtils.isBlank(token) || token.equals("[object Undefined]")){
// 没带token
throw new BusinessException(BusinessExceptionEumn.UNAUTHORIZED);
}
return true;
}
前端放在请求头的token,可能会有不同的名字,一般叫token或者Authrization,或者X-Access-Token,随你喜欢,如果要换个名字,String token = request.getHeader("Authrization");里的Authrization就要换成对应的名字。
可以看到,没有token,就直接抛业务异常,全局异常处理器会捕捉这里的异常并返回给前端的。
获取到了前端的token,我们就可以拿这个token去数据库里查出用户信息。
首先需要在UserTokenService里,写一个根据token查出用户信息的方法。
联表查询不建议用MyBatis-plus实现,还是手写的好,因此要自定义mapper层的方法:
Mapper层:
package com.TandK.mapper; import com.TandK.model.po.UserPO; import com.TandK.model.po.UserTokenPO; import com.baomidou.mybatisplus.core.mapper.baseMapper; public interface UserTokenMapper extends baseMapper{ UserPO selectUserByToken(String token); }
mapper的自定义sql可以用注解实现,就下面这种写法。
package com.TandK.mapper; import com.TandK.model.po.UserPO; import com.TandK.model.po.UserTokenPO; import com.baomidou.mybatisplus.core.mapper.baseMapper; import org.apache.ibatis.annotations.Select; public interface UserTokenMapper extends baseMapper{ @Select("SELECT u.* FROM user u LEFT JOIN user_token ut ON u.uuid = ut.user_uuid WHERe ut.access_token = #{token} AND expire_time >= NOW() AND u.is_delete = 0") UserPO selectUserByToken(String token); }
也可以用XML的方法,个人还是喜欢xml的写法的,那样写出来的sql可以足够长,看着也足够有结构感,不会太乱。
这里贴一下XML的写法,XML或者注解,这两种写法挑一个即可。
XML需要新建一个XML的文件,在项目的resources文件夹下新建一个mapper文件夹。
然后新建一个XML文件,注意要和对应的mapper.java同名。
对应的说明在下图:
还是那句话,自定义sql的方式,注解和XML的方式选一个就行了,我是推荐用XML的方式,如果项目大了,用XML方式写出来的SQL有时还能占一整个屏幕,用注解的方式写的话会很辣眼睛,如果用了XML就把注解的方式删掉就行。
接下来贴一下service层的代码:
package com.TandK.service;
import com.TandK.model.po.UserPO;
public interface UserTokenService {
UserPO getUserByToken(String token);
}
package com.TandK.service.impl;
import com.TandK.mapper.UserTokenMapper;
import com.TandK.model.po.UserPO;
import com.TandK.service.UserTokenService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
@Service
public class UserTokenServiceImpl implements UserTokenService {
@Autowired
private UserTokenMapper userTokenMapper;
@Override
public UserPO getUserByToken(String token) {
return userTokenMapper.selectUserByToken(token);
}
}
根据token查询用户信息的方法写好之后,就可以回到拦截器了,用拿到的token查一遍:
package com.TandK.core.interceptor;
import com.TandK.core.annotation.IgnoreToken;
import com.TandK.core.exception.BusinessException;
import com.TandK.core.exception.BusinessExceptionEumn;
import com.TandK.model.po.UserPO;
import com.TandK.service.UserTokenService;
import com.baomidou.mybatisplus.core.toolkit.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.HandlerInterceptor;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.lang.reflect.Method;
public class AuthInterceptor implements HandlerInterceptor {
@Autowired
private UserTokenService userTokenService;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
HandlerMethod handlerMethod = (HandlerMethod) handler;
Method method = handlerMethod.getMethod();
// 判断有没有IgnoreToken注解,如果有,直接放行
if(method.isAnnotationPresent(IgnoreToken.class)){
return true;
}
// 先获取前端传来的token,这里的Authrization是小程序推荐的叫法,具体要和前端传来的变量名一致
String token = request.getHeader("Authrization");
if(StringUtils.isBlank(token) || token.equals("[object Undefined]")){
// 没带token
throw new BusinessException(BusinessExceptionEumn.UNAUTHORIZED);
}
// 根据token获取用户信息
UserPO userPO = userTokenService.getUserByToken(token);
if(userPO == null){
// 鉴权失败
throw new BusinessException(BusinessExceptionEumn.UNAUTHORIZED);
}
return true;
}
}
这样,如果通过了前面的重重检查,到了最后return true;就可以放行了。
但是,查出来的用户信息,我们可以放到内存中,方便后面需要的时候再随时提取出来。
这个时候我们可以用SpringBoot的ThreadLocal来存储用户信息。
简单地说明一下ThreadLocal是个什么东西。
可以这么理解,每一次访问都会有一个线程,ThreadLocal其实是Map
由于生命周期只在一次访问内,因此很适合我们这个场景。
我们在拦截器里把用户信息存起来,可以在这次请求内的任意地方拿出来,可以是controller层、service层。
在之前的core.support包下新建一个threadlocal包:
新建一个UserThreadLocal静态类,具体的代码实现方式如下:
package com.TandK.core.support.threadlocal;
import com.TandK.model.po.UserPO;
public class UserThreadLocal {
private static ThreadLocal userThreadLocal = new ThreadLocal();
public static void set(UserPO userPO){
userThreadLocal.set(userPO);
}
public static UserPO get(){
return userThreadLocal.get();
}
public static void remove(){
userThreadLocal.remove();
}
}
用的话,无非就是存和取用户信息,我们回到拦截器那里,把通过校验的用户信息存起来,这样,拦截器的功能就完整了:
package com.TandK.core.interceptor;
import com.TandK.core.annotation.IgnoreToken;
import com.TandK.core.exception.BusinessException;
import com.TandK.core.exception.BusinessExceptionEumn;
import com.TandK.core.support.threadlocal.UserThreadLocal;
import com.TandK.model.po.UserPO;
import com.TandK.service.UserTokenService;
import com.baomidou.mybatisplus.core.toolkit.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.HandlerInterceptor;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.lang.reflect.Method;
public class AuthInterceptor implements HandlerInterceptor {
@Autowired
private UserTokenService userTokenService;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
HandlerMethod handlerMethod = (HandlerMethod) handler;
Method method = handlerMethod.getMethod();
// 判断有没有IgnoreToken注解,如果有,直接放行
if(method.isAnnotationPresent(IgnoreToken.class)){
return true;
}
// 先获取前端传来的token,这里的Authrization是小程序推荐的叫法,具体要和前端传来的变量名一致
String token = request.getHeader("Authrization");
if(StringUtils.isBlank(token) || token.equals("[object Undefined]")){
// 没带token
throw new BusinessException(BusinessExceptionEumn.UNAUTHORIZED);
}
// 根据token获取用户信息
UserPO userPO = userTokenService.getUserByToken(token);
if(userPO == null){
// 鉴权失败
throw new BusinessException(BusinessExceptionEumn.UNAUTHORIZED);
}
// 存储用户信息
UserThreadLocal.set(userPO);
return true;
}
}
之后随时有需要拿出用户信息,只需要像下面这样写就可以了:
UserPO userPO = UserThreadLocal.get();
这样就可以拿出此次访问当前接口的用户信息。
上面说完了token的校验和用户信息的存取。
接下来说一下token的创建和更新,一般我们都是在用户登录的时候。
首先登录的接口要写上@IgnoreToken注解,忽略token的校验。
然后检查用户是否第一次登录,这个查一下有没有这个用户的账号密码就能知道。
如果是第一次登录,那么就生成token,如果不是,那就刷新token和token的有效时间就好。
写一个登录接口,首先还是回去之前建好的user表,加上账号和密码的字段。
注册接口就不写了,账号密码直接从数据库里塞进去吧。
数据库添加了字段,对应的po也要添加字段,不然后面可能出现一些问题:
建立一个LoginVO,用来接收登录接口的参数,做好校验:
package com.TandK.model.vo;
import lombok.Data;
import javax.validation.constraints.NotBlank;
@Data
public class LoginVO {
@NotBlank(message = "账号不能为空")
private String account;
@NotBlank(message = "密码不能为空")
private String password;
}
controller层的代码:
这里登录的逻辑都写在注释里了,可以好好看看:
package com.TandK.service.impl;
import com.TandK.core.exception.BusinessException;
import com.TandK.core.exception.BusinessExceptionEumn;
import com.TandK.core.jwt.JwtUtil;
import com.TandK.core.support.http.HttpResponseSupport;
import com.TandK.mapper.UserMapper;
import com.TandK.model.po.UserPO;
import com.TandK.model.vo.LoginVO;
import com.TandK.model.vo.UserVO;
import com.TandK.service.UserService;
import com.TandK.service.UserTokenService;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.toolkit.StringUtils;
import org.springframework.beans.BeanUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import java.util.*;
import java.util.stream.Collectors;
@Service
public class UserServiceImpl implements UserService {
@Autowired
private UserMapper userMapper;
@Autowired
private UserTokenService userTokenService;
@Override
public ResponseEntity
package com.TandK.turntable.service.impl;
import cn.hutool.core.date.DateUtil;
import com.TandK.turntable.mapper.UserTokenMapper;
import com.TandK.turntable.model.po.UserPO;
import com.TandK.turntable.model.po.UserTokenPO;
import com.TandK.turntable.service.UserTokenService;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.Date;
@Service
public class UserTokenServiceImpl implements UserTokenService {
@Autowired
private UserTokenMapper userTokenMapper;
@Transactional(rollbackFor = Throwable.class)
@Override
public void saveToken(String uuid, String token) {
// 查出原先有用的token
QueryWrapper wrapper = new QueryWrapper();
wrapper.eq("user_uuid", uuid)
.orderByDesc("update_time")
.last("LIMIT 1");
UserTokenPO oldToken = userTokenMapper.selectOne(wrapper);
// token的有效时间我定为一个月,DateUtil这个工具类下面有贴依赖
Date nextMonth = DateUtil.nextMonth();
if(oldToken == null){
// 没有就生成新的token
UserTokenPO userTokenPO = new UserTokenPO();
userTokenPO.setUserUuid(uuid);
userTokenPO.setAccessToken(token);
userTokenPO.setExpireTime(nextMonth);
userTokenMapper.insert(userTokenPO);
return;
}
// 有就更新
oldToken.setAccessToken(token);
oldToken.setExpireTime(nextMonth);
userTokenMapper.updateById(oldToken);
}
}
上面DateUtil的工具类的依赖如下:
cn.hutool hutool-all 5.1.0
由于接口是post,需要用调试工具(没有工具也没关系,下一篇介绍的Swagger,文档工具可以提供调试功能,不需要你去安装调试软件):
返回接口如下,圈起来的就是token的值:
前端同学需要把这个token存起来,再在每次请求的时候把token带在请求头上。
那么关于token校验流程就到此为止,还有些细节你可以试试,比如在需要token校验的接口,访问它的时候不带token,比如在代码中用UserThreadLocal获取用户信息,这些都可以自己去试一试。
有什么问题可以留言,看到了都会尽量帮忙解决。



