一、写在前面
目前Token实现登录功能有两种常见的技术方案:JWT 和 token+缓存 。这两种方案各有优缺点,本文使用方案是token+缓存。
1.1 为什么不使用JWT
JWT(JSON Web Token),是一种Auth0提出的授权方案,它每次生成如下的字符串(这里被点隔开为三部分,分别是头部、负载和签名):
eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.
eyJzZXgiOiLnlLciLCJ1c2VyTmFtZSI6IuW8oOS4iSIsImV4cCI6MTY5ODIzMjA5MiwiaWF0IjoxNjk4MjIxMjkyfQ.
X7HGEVTtPc25wj_NQP7Lv0HpWIY6k-9_5ioNMAWxxY4
它的特点如下:
A.减少对数据库的访问
由于用户的信息被存在于token的负载中,当需要获取用户信息时,可以直接从Token中解析,从而避免对数据库的访问,进一步降低数据库的负荷
但是,这个特性同时也导致了一旦用户数据发生变更,存储在token中的信息就与数据库中的信息不一致。
比如,用户在后台对姓名字段进行了变更,前台传递过来的token中依然包含旧的用户名称,此时就存在"脏数据"的情况
B.对分布式和跨端友好
由于签名算法可以保证生成的token不重复,因而在分布式和(SSO)单点登录中经常使用
综合以上,对于无分布式需求且业务易变更的应用,完全可以使用token+缓存的形式来实现token登录,这种技术方案实际上是将JWT的唯一性 + 用户信息数据存储 的功能切分开了,灵活性更高
1.2缓存的实现方案选择
在token+缓存这套框架里,缓存的选择并非只有Redis,我们也可以自己实现简单的缓存存储功能。不采用redis的原因有:
- 在仅仅使用缓存作为登录信息存储的情况下,没必要引入额外的模块,除非项目中存在大量的缓存读写需求
- redis常年存在高风险漏洞,是安全漏扫问题的常客
如果项目的要求是小型稳定、简单安全,那么我们完全可以手写缓存工具,而不是引入额外的模块。
二、时序图
三、工具类及框架准备
3.1工具类准备
3.1.1 token生成工具类
对于这个工具类我们的要求是返回具有唯一性的字符串(甚至可以使用uuid进行生成),以下为详细代码:
java
public class TokenGenerateUtil {
/**
* 盐值-替换为你自己的盐值
*/
private static final String SALT_VALUE="saltValue";
/**
* 生成盐值的算法名称
*/
private static final String SALT_ALGORITHM = "SHA-1";
/**
* hash算法名称
*/
private static final String HASH_ALGORITHM = "SHA-256";
/**
* 生成token
* @param data 传入的数据
* @return 返回token
*/
private static String generateToken(String data) {
String token = null;
try {
//时间戳
String timeStamp=String.valueOf(System.currentTimeMillis());
// 添加盐
String dataWithSalt = data + SALT_VALUE+timeStamp;
// 创建SHA-1散列
MessageDigest saltDigest = MessageDigest.getInstance(SALT_ALGORITHM);
saltDigest.update(SALT_VALUE.getBytes());
byte[] saltBytes = saltDigest.digest();
// 使用Base64编码
String saltBase64 = Base64.getEncoder().encodeToString(saltBytes);
// 将数据与盐的散列值结合,然后计算SHA-256散列
MessageDigest md = MessageDigest.getInstance(HASH_ALGORITHM);
md.update((dataWithSalt + saltBase64).getBytes());
// 使用Base64编码
byte[] byteData = md.digest();
token = Base64.getEncoder().encodeToString(byteData);
} catch (NoSuchAlgorithmException e) {
log.error("生成token失败,未找到对应的算法:{}",e.getMessage());
}
return token;
}
public static void main(String[] args) {
System.out.println(TokenGenerateUtil.generateToken("123"));
}
}
3.1.2 缓存工具类
我们对缓存工具类的需求如下:
- 需要提供生成、销毁、更新和读取缓存的接口
- 需要有过期时间
- 需要线程安全
根据上述要求,我们先设计如下的Cache存储对象:
java
@AllArgsConstructor
public class CacheEntry<V> {
//存储数据
private final V value;
//过期时间
private final long expirationTimeMillis;
/**
* 是否过期
* @return 布尔值
*/
public boolean isExpired() {
return System.currentTimeMillis() > expirationTimeMillis;
}
public V getValue() {
return value;
}
}
下面我们需要一个工具类用于操作缓存,代码如下:
java
@Component
public class CacheManagerUtil<K,V> {
private final Map<K, CacheEntry<V>> cacheMap;
private final ScheduledExecutorService scheduler;
/**
* 默认过期时间 3小时
*/
public static long TTL=1000*60*60*3L;
public CacheManagerUtil() {
cacheMap = new ConcurrentHashMap<>();
scheduler = Executors.newScheduledThreadPool(1);
}
/**
* 存值
* @param key 键
* @param value 值
* @param expirationTimeMillis 过期时间
*/
public void put(K key, V value, long expirationTimeMillis) {
expirationTimeMillis += System.currentTimeMillis();
CacheEntry<V> entry = new CacheEntry<>(value, expirationTimeMillis);
cacheMap.put(key, entry);
// 定时任务,在过期时间后自动销毁缓存条目
scheduler.schedule(() -> cacheMap.remove(key), expirationTimeMillis, TimeUnit.MILLISECONDS);
}
/**
* 根据键取值
* @param key 键
* @return 值
*/
public V get(K key){
CacheEntry<V> entry = cacheMap.get(key);
if (entry != null && !entry.isExpired()) {
return entry.getValue();
}
return null;
}
/**
* 根据建删除对应的键值对
* @param key 键
*/
public void remove(K key) {
cacheMap.remove(key);
}
/**
* 获取缓存键列表
* @return 缓存键列表
*/
public List<K> getKeys(){
return new ArrayList<>(cacheMap.keySet());
}
3.2 拦截器框架准备
对于拦截器框架,我们的需求是能对非登录请求进行拦截,不符合要求的驳回。以下是拦截器和拦截器配置
这里是具体的拦截器:
java
@Component
@Slf4j
public class UserInterceptor implements HandlerInterceptor {
/**
* token在请求头中的存储位置
*/
private static final String TOKEN_HEADER_STORAGE_NAME="Authorization";
private final ObjectMapper objectMapper;
private final CacheManagerUtil<String, UserVO> cacheManagerUtil;
public UserInterceptor(ObjectMapper objectMapper, CacheManagerUtil<String, UserVO> cacheManagerUtil) {
this.objectMapper = objectMapper;
this.cacheManagerUtil = cacheManagerUtil;
}
/**
* 请求处理之前对用户信息进行处理
* @param request 请求
* @param response 响应
* @param handler 处理器
* @return 是否通过
*/
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws IOException {
String token=request.getHeader(TOKEN_HEADER_STORAGE_NAME);
//验证token是否存在
if(!StringUtils.hasText(token)){
sendResponse(response,HttpResultEnum.TOKEN_IS_EMPTY);
return false;
}
//验证token是否有效
try {
UserVO userVO= cacheManagerUtil.get(token);
if(userVO==null){
sendResponse(response,HttpResultEnum.TOKEN_IS_INVALID);
return false;
}
return true;
} catch (Exception e){
sendResponse(response,HttpResultEnum.TOKEN_IS_INVALID);
}
return false;
}
/**
* 手动设置响应
* @param response 响应
* @param httpResultEnum 响应结果枚举
* @throws IOException IO异常
*/
public void sendResponse(HttpServletResponse response,HttpResultEnum httpResultEnum) throws IOException{
response.setContentType("application/json;charset=UTF-8");
response.setCharacterEncoding("utf-8");
ResponseResult<String> responseResult=new ResponseResult<>(httpResultEnum);
response.getWriter().write(objectMapper.writeValueAsString(responseResult.toJsonString()));
}
}
这里是拦截器配置:
java
@Configuration
public class InterceptorConfig implements WebMvcConfigurer {
private final UserInterceptor userInterceptor;
public InterceptorConfig(UserInterceptor userInterceptor) {
this.userInterceptor = userInterceptor;
}
@Override
public void addInterceptors(InterceptorRegistry registry) {
//拦截器路径处理 拦截所有路径
registry.addInterceptor(userInterceptor)
.addPathPatterns("/api/**")
//不需要拦截的请求 -登录请求
.excludePathPatterns("/api/login")
;
}
//配置静态资源
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
registry.addResourceHandler("/**")
.addResourceLocations("classpath:/resources/");
}
}
四、流程分析
4.1 验证登录信息并返回token
首先,我们写一个简单的登录接口,它需要完成如下的事情:
1.校验登录信息
2.生成token并存入缓存
3.返回登录结果
登录代码如下:
java
/**
* 登录
* @param loginDTO 登录DTO
* @return JSON
*/
@GetMapping("/login")
public ResponseResult<LoginVO> login(@RequestBody LoginDTO loginDTO){
ResponseResult<LoginVO> responseResult=new ResponseResult<>();
UserVO userVO = loginService.verifyLogin(loginDTO);
if(userVO==null){
return responseResult.setHttpResultEnum(HttpResultEnum.PARAM_IS_ERROR);
}
//生成token并缓存
String token=TokenGenerateUtil.generateToken(userVO.toString());
//存入缓存 有效期3小时
cacheManagerUtil.put(token,userVO,CacheManagerUtil.TTL);
//返回token和VO
LoginVO loginVO=new LoginVO(token,userVO);
return responseResult.setData(loginVO);
}
请求参数如下:
json
{
"account":"10",
"password":"pwd1"
}
测试结果如下:
json
{
"code": 200,
"msg": "操作成功",
"data": {
"token": "gmxaN2GGzH0vJAGjzioDIvdjAbCo18SyXkDJYvS2kfU=",
"user": {
"userName": "张三"
}
}
}
这里,我们再测试一下缓存中是否已经存入,测试代码如下:
java
/**
* 获取缓存用户信息
* @return JSON
*/
@GetMapping("/listCacheUserInfo")
public ResponseResult<String> listCacheUserInfo(){
ResponseResult<String> responseResult=new ResponseResult<>();
List<String> keySet= cacheManagerUtil.getKeys();
StringBuilder stringBuilder=new StringBuilder();
keySet.forEach(key-> stringBuilder.append(key).append(": ").append(cacheManagerUtil.get(key)).append("\n"));
return responseResult.setData(stringBuilder.toString());
}
返回结果:

4.2 携带token进行正常请求的访问
因为我们配置了拦截器,对于登录以外的请求系统会统一进行拦截,这里我们写一个测试接口:
java
/**
* 测试路由
* @return JSON
*/
@GetMapping("/testRoute")
public ResponseResult<String> testRoute(){
ResponseResult<String> responseResult=new ResponseResult<>();
return responseResult.setData("成功进入");
}
使用apiFox发送请求,我们在拦截器这里打下断点,拦截器会对请求的头部中取出token,并进行判空:

可以看到,如果token为空,拦截值直接返回对应的失败响应,这里是apiFox的测试返回结果:

同理,对于错误的token,会前往缓存中查取,如果不存在则同样返回失败响应:

4.3上下文存入用户信息并访问
在前两步操作中,我们存储并校验了token,为了方便后续代码中我们可以直接获取当前用户ID,而不需要前端通过参数传递的形式发送过来,我们可以将用户信息存入上下文中,并封装方法到工具类中。
4.3.1上下文工具类与拦截器配置
我们对这个工具类的要求如下:
- 线程隔离
- 提供存入和删除等等接口
工具类如下:
java
public final class ContextUtil {
/**
* 获取当前用户id
* @return 用户id
*/
public static String getCurrentUserName() {
return Optional.ofNullable(getCurrentUser()).map(UserVO::getUserName).orElseThrow(() -> new RuntimeException("获取当前用户失败"));
}
/**
* 用户线程变量
*/
private static final ThreadLocal<UserVO> USER_HOLDER = new ThreadLocal<>();
/**
* set方法
* @param user user
*/
public static void setCurrentUser(UserVO user) {
USER_HOLDER.set(user);
}
/**
* get方法
* @return UserVO
*/
@Nullable
public static UserVO getCurrentUser() {
return USER_HOLDER.get();
}
/**
* 清除当前用户
*/
public static void clearCurrentUser() {
USER_HOLDER.remove();
}
/**
* 工具类不需要生成实例
*/
private ContextUtil() {
}
}
接下来我们需要在拦截器的preHandle和afterCompletion中进行配置。
**a.**在每个请求处理之前我们都把用户信息存入到上下文中
java
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws IOException {
String token=request.getHeader(TOKEN_HEADER_STORAGE_NAME);
//验证token是否存在
if(!StringUtils.hasText(token)){
sendResponse(response,HttpResultEnum.TOKEN_IS_EMPTY);
return false;
}
//验证token是否有效
try {
UserVO userVO= cacheManagerUtil.get(token);
if(userVO==null){
sendResponse(response,HttpResultEnum.TOKEN_IS_INVALID);
return false;
}
ContextUtil.setCurrentUser(userVO);
return true;
} catch (Exception e){
sendResponse(response,HttpResultEnum.TOKEN_IS_INVALID);
}
return false;
}
**b.**处理之后,我们从上下文中移除对应的用户信息
java
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler,
@Nullable Exception ex) {
ContextUtil.clearCurrentUser();
}
4.3.2实际使用和测试
测试代码如下:
java
/**
* 测试当前用户
* @return JSON
*/
@GetMapping("/testCurrentUser")
public ResponseResult<String> testCurrentUser(){
ResponseResult<String> responseResult=new ResponseResult<>();
UserVO userVO= ContextUtil.getCurrentUser();
log.info("当前用户信息: {}",userVO);
return responseResult.setData("");
}
五、git仓库
gitHub:
github.com/ThreeBody19...