引入组件
<!-- 引入JWT依赖 -->
<dependency>
<groupId>com.auth0</groupId>
<artifactId>java-jwt</artifactId>
<version>3.4.0</version>
</dependency>
创建JWT工具类
import com.alibaba.druid.util.StringUtils;
import com.auth0.jwt.JWT;
import com.auth0.jwt.JWTCreator;
import com.auth0.jwt.JWTVerifier;
import com.auth0.jwt.algorithms.Algorithm;
import com.auth0.jwt.interfaces.DecodedJWT;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import java.text.SimpleDateFormat;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.time.temporal.Temporal;
import java.util.Calendar;
import java.util.Date;
/***
* Author: YL.Lou
* Class: JWTUtil
* Project: washes_base_backstage
* Introduce: JWT的验证工具类 被生成Token的方法Service调用 也会被 拦截器调用 验签
* DateTime: 2022-06-29 19:23
***/
@Slf4j
@Component
public class JWTUtil {
// 为Redis存储准备的Key前缀 后边跟的是用户的 ID 例如:JWT_SIGN_1 查询到的是 MD5 之后的 用户密码 信息
public static final String SIGN = "JWT_SIGN_";
// 加盐
private static final String SECRET = "lou123321!!!";
@Autowired
private RedisUtil redisUtil;
/**
* 获取token
* @param u user
* @return token
*/
public String getToken(User u) {
Calendar instance = Calendar.getInstance();
//默认令牌过期时间30天
SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
instance.add(Calendar.DATE, 7);
JWTCreator.Builder builder = JWT.create();
builder.withClaim("userId", String.valueOf(u.getuId()))
.withClaim("userPhone", u.getuPhone())
.withClaim("userEmail", u.getuEmail())
.withClaim("userLoginTime", String.valueOf(u.getuLoginTime()))
.withClaim("userName", u.getuName())
.withClaim("expTime", simpleDateFormat.format(new Date(instance.getTime().getTime())));
// 将 用户ID + 用户密码 用MD5 混淆 再加盐 获取的字符串 用来生成签名
return builder.withExpiresAt(instance.getTime())
.sign(Algorithm.HMAC256(BaseUtil.getMD5(u.getuId() + u.getuPassword()) + SECRET));
}
/**
* 验证token合法性 成功返回token
*/
public DecodedJWT verify(String token, Long uId) throws Exception {
// 从Redis中获取用户ID + 密码 并被MD5 混淆后的字符串
String strSign = redisUtil.get(SIGN + uId);
if(null == strSign){
throw new Exception("Original Token 无效或已过期");
}
if(StringUtils.isEmpty(token)){
throw new Exception("token不能为空");
}
JWTVerifier build = JWT.require(Algorithm.HMAC256(strSign + SECRET)).build();
return build.verify(token);
}
/* public static void main(String[] args) {
DecodedJWT verify = verify("eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJleHAiOjE2MTcxMDg1MDAsInVzZXJuYW1lIjoiYWRtaW4ifQ.geBEtpluViRUg66_P7ZisN3I_d4e32Wms8mFoBYM5f0");
System.out.println(verify.getClaim("password").asString());
}*/
}
创建一个拦截器 将验证工具注入拦截器
这里便用到了很多我自己写的工具类和实体类 包括Redis的工具类
在前面的文章中都能找到相应的文件
import com.alibaba.druid.util.StringUtils;
import com.auth0.jwt.exceptions.AlgorithmMismatchException;
import com.auth0.jwt.exceptions.SignatureVerificationException;
import com.auth0.jwt.exceptions.TokenExpiredException;
import com.auth0.jwt.interfaces.DecodedJWT;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.time.format.DateTimeFormatter;
/***
* Author: YL.Lou
* Class: JWTInterceptor
* Project: washes_base_backstage
* Introduce: JWT 拦截器
* DateTime: 2022-06-29 19:34
***/
@Slf4j
@Component
public class JWTInterceptor implements HandlerInterceptor {
@Autowired
private JWTUtil jwtUtil;
@Autowired
private RedisUtil redisUtil;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 从Header中获得Token 和 uid 这两个是要与前端同步的
String token = request.getHeader("token");
String uid = request.getHeader("uid");
if(StringUtils.isEmpty(token)){
throw new Exception("Header 未装载 token");
}
if(StringUtils.isEmpty(uid)){
throw new Exception("Header 未装载 uid");
}
try {
// 得到签名实体
DecodedJWT verify = jwtUtil.verify(token, Long.valueOf(uid));
// 得到签名中的登录时间
String loginTimeFromToken = verify.getClaim("userLoginTime").asString();
log.info("token:" + loginTimeFromToken);
// 从Redis中获取用户的信息
User user = redisUtil.getObject(RedisPrefix.USER + uid, User.class);
String loginTimeFromRedis = BaseUtil.localDateTime2String(user.getuLoginTime());
log.info("redis:" + loginTimeFromRedis);
if (!loginTimeFromRedis.equals(loginTimeFromToken)){
throw new Exception("用户Token已更新");
}
} catch (SignatureVerificationException e) {
throw new Exception("无效Token签名");
} catch (TokenExpiredException e) {
throw new Exception("token过期");
} catch (AlgorithmMismatchException e) {
throw new Exception("token算法不一致");
} catch (Exception e) {
throw new Exception("token无效:" + e.getMessage());
}
return true;
}
}
这个拦截器就是调用了工具类中验证Token的部分 对前端用户传回的Token是否合法 以及是否保存了UID等信息进行验证
验证通过就放行
验证失败就抛出异常 由统一错误处理类承接 并最终给前端返回错误信息
配置拦截器 注入到SpringBoot容器
import cn.ihuanxi.base.interceptor.JWTInterceptor;
import org.springframework.beans.factory.annotation.Autowired;
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 InterceptorConfig implements WebMvcConfigurer {
@Autowired
private JWTInterceptor jwtInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(jwtInterceptor)
//拦截的路径
.addPathPatterns("/**")
//排除登录接口
.excludePathPatterns("/public/**");
}
}
代码很简单,处理的业务逻辑也很简单,就是一个AOP思想的严重体现
生成Token的部分
前端用户拿到的Token通常是在Service层调用JWTUtil生成
/**
* 根据短信验证码完成登录
* @param uPhone
* @param smsCode
* @return
*/
@DS("slaver")
@Override
public Result loginBySmsCode(String uPhone, String smsCode) {
// 通过检查Redis中是否存在该手机的值即可实现
String redisValue = redisUtil.get(RedisPrefix.SMS_CODE + uPhone);
// 如果不为null 说明刚刚发送过手机短信
if (null == redisValue || redisValue.equals("")) {
return result.failed("短信验证码无效", smsCode);
}
// 判断输入的短信验证码是否正确
if (!smsCode.equals(redisValue)) {
return result.failed("短信验证码错误", smsCode);
}
// 验证成功后该验证码就会被移除
redisUtil.delete(RedisPrefix.SMS_CODE + uPhone);
// 根据手机号获取用户信息
QueryWrapper<User> userQueryWrapper = new QueryWrapper<>();
userQueryWrapper.eq("u_phone",uPhone);
User user = userMapper.selectOne(userQueryWrapper);
// 判断用户是否存在
if (null == user){
// TODO: 如果开启自动注册 则此处应该进行二次处理 2022-7-2 16:15:12
return result.failed("用户手机号码未注册");
}
// 更新用户的登录时间 和 手机号码认证状态
user.setuLoginTime(BaseUtil.str2LocalDateTime(null));
user.setuPhoneChecked(true);
// 保存用户登录时间和手机认证状态
userMapper.updateById(user);
// 将用户ID+密码 MD5后 存到Redis,用作签名的同时方便后期查验 (30天过期)
redisUtil.add(RedisPrefix.TOKEN_SIGN + user.getuId().toString(), BaseUtil.getMD5(user.getuId()+user.getuPassword()), 30, TimeUnit.DAYS);
// 生成Token
String token = jwtUtil.getToken(user);
user.setToken(token);
// 检查用户是否设置过密码 并将结果写入
user.setPassword(null != user.getuPassword() && !user.getuPassword().equals(""));
// 将用户所有数据存入Redis 方便后续程序随时调用 避免频繁查库
redisUtil.add(RedisPrefix.USER + user.getuId().toString(), user);
// 将生成好的Token返回给前端
return result.success("用户登录成功", user);
}
关于密码修改后旧Token失效 以及 单点登录的简单实现
上边的代码很复杂,其核心流程大概就是:
- 通过查库中的用户密码和登录时间生成一个 签名用的字符串
- 将字符串保存到Redis中
- 然后用生成的字符串去给Token值签名
- 前端将Token回传时,拦截器拿到用户UID
- 根据UID查Redis中的签名字符串 其实就是用户密码和登录时间
- 验签通过后就能拿到Token中携带的用户登录时间
- 用Token中的用户登录时间和Redis保存的用户登录时间对比 不同则验证失败(以此可以实现单点登录)