单体应用下的登录及鉴权(Token+缓存实现)

一、写在前面

目前Token实现登录功能有两种常见的技术方案:JWTtoken+缓存 。这两种方案各有优缺点,本文使用方案是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的原因有:

  1. 在仅仅使用缓存作为登录信息存储的情况下,没必要引入额外的模块,除非项目中存在大量的缓存读写需求
  2. redis常年存在高风险漏洞,是安全漏扫问题的常客

如果项目的要求是小型稳定、简单安全,那么我们完全可以手写缓存工具,而不是引入额外的模块。

二、时序图

token的验证机制如下:

三、工具类及框架准备

3.1工具类准备

根据时序图,我们需要一个token生成的工具类和缓存工具类

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 缓存工具类

我们对缓存工具类的需求如下:

  1. 需要提供生成、销毁、更新和读取缓存的接口
  2. 需要有过期时间
  3. 需要线程安全

根据上述要求,我们先设计如下的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());
}

返回结果:

到这里登录获取token和用户信息的流程就已经结束了

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上下文工具类与拦截器配置

我们对这个工具类的要求如下:

  1. 线程隔离
  2. 提供存入和删除等等接口

工具类如下:

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仓库

码云:
gitee.com/inspiration...

gitHub:
github.com/ThreeBody19...

相关推荐
吴佳浩6 小时前
Python入门指南(六) - 搭建你的第一个YOLO检测API
人工智能·后端·python
踏浪无痕6 小时前
JobFlow已开源:面向业务中台的轻量级分布式调度引擎 — 支持动态分片与延时队列
后端·架构·开源
Pitayafruit7 小时前
Spring AI 进阶之路05:集成 MCP 协议实现工具调用
spring boot·后端·llm
ss2737 小时前
线程池:任务队列、工作线程与生命周期管理
java·后端
不像程序员的程序媛7 小时前
Spring的cacheEvict
java·后端·spring
踏浪无痕7 小时前
JobFlow 实战:无锁调度是怎么做到的
后端·面试·架构
shoubepatien8 小时前
JAVA -- 11
java·后端·intellij-idea
喵个咪8 小时前
开箱即用的 GoWind Admin|风行,企业级前后端一体中后台框架:kratos-bootstrap 入门教程(类比 Spring Boot)
后端·微服务·go
uzong8 小时前
从大厂毕业后,到小公司当管理,十年互联网老兵的思维习惯阶段复盘
后端
追逐时光者8 小时前
一个 WPF 开源、免费的 SVG 图像查看控件
后端·.net