前后端分离项目——登录Token校验思路

前言

根据token校验当前用户登录状态是Web项目的常见手段,我给自己的项目做token校验功能时,发现网上很多文章代码高度相似,实现的思路也差不多(基本都是前端校验后从router入手去做页面拦截)。所以想自己写一篇文章记录一下自己实现的思路,实现功能的前提在于需求,希望能够给相关开发人员一个参考。

需求思考

对token的校验分为前端和后端

  • 对后台来说,并不是所有的请求都需要用户登录后才可以执行,所以需要后台去鉴别需要拦截的需求,用户是否满足已登录的状态。
  • 对前端来说,也并不是用户没有登录,就一定不允许访问某个页面,可能只是说不允许访问某个页面的某些功能而已。所以使用router进行登录校验的时候,不要一棒子打死,最好是先确定好想要实现什么样的功能,再去设计代码

代码实现思路

  • 用户前端登录成功,后台将用户的唯一token存入redis(有效期30min)中,并返回给前端
  • 前端接收到token后,将其存放到session缓存,每次发起请求时将token封装到请求头head中
  • 后台根据token是否有效,无效则拒绝请求
  • 前端返回结果

一、环境介绍

前端: Vue-Cli 2.x + axios
后端:SpringBoot 2.3.4

二、前端代码

1、成功登录后回调函数封装用户tokenuserId(为什么要传userId后面会说)
2、token和userId放在全局参数store中
const user = {
    state: {
        userId: '',
        userToken: '',  // 用户token,用户确认当前用户是否登录
    },
    getters: {
        userId: state => {
            let userId = state.userId;
            if(!userId){
                userId =  JSON.parse(window.sessionStorage.getItem('userId'));
            }
            return userId;
        },
        userToken: state => {
            let userToken = state.userToken;
            if(!userToken){
                userToken = JSON.parse(window.sessionStorage.getItem('userToken'));
            }
            return userToken;
        },
    },
    mutations: {
        setUserId: (state,userId) => {
            state.userId = userId;
            window.sessionStorage.setItem('userId',JSON.stringify(userId));
        }, 
        setUserToken: (state,userToken) => {
            state.userToken = userToken;
            window.sessionStorage.setItem('userToken',JSON.stringify(userToken));
        }, 
    }
}
export default user;

这里的话,userToken和userId放到sessionStorage是关键步骤

3、使用 axios.interceptors.request.use对axios的请求进行统一拦截,封装token和userId
import axios from 'axios';
import router from '../router';
// 设置请求拦截器
axios.interceptors.request.use(function (config) {
    // Do something before request is sent
    //window.localStorage.getItem("accessToken") 获取token的value
    let token = JSON.parse(window.sessionStorage.getItem('userToken'));
    let userId = JSON.parse(window.sessionStorage.getItem('userId'));
    if (token && userId) {
        //将token放到请求头发送给服务器,将tokenkey放在请求头中
        console.log(token);
        console.log(userId);
        config.headers.userId = userId;
        config.headers.userToken = token;     
        //也可以这种写法
        // config.headers['accessToken'] = token;
    }
    return config;
}, function (error) {
    // Do something with request error
    return Promise.reject(error);
});

三、后端

后端主要是使用拦截器来进行请求的拦截和校验

1、定义拦截器
package com.qiqv.music.controller.interceptor;

import com.qiqv.music.utils.JSONUtils;
import com.qiqv.music.utils.QiqvJSONResult;
import com.qiqv.music.utils.RedisOperator;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.servlet.HandlerInterceptor;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.io.OutputStream;

/**
 * 自定义拦截器类
 */
public class MiniInterceptor implements HandlerInterceptor {

    
    private RedisOperator redisOperator;

    // token规则为 user-reids-token:userId : UUID
    private static String USER_REDIS_TOKEN = "user-redis-token";


    /**
     * 判断用户是否登录
     *  若用户userId不存在,则为未登录
     *  若用户userId存在,则判断token是否存在
     *      若存在,则用户状态为已登录
     *      若不存在,则用户状态为登录超时
     * @param request
     * @param response
     * @param handler
     * @return
     * @throws Exception
     *
     */
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        // 如果是 嗅探请求,则直接放行
        if("OPTIONS".equals(request.getMethod())){
            return true;
        }
        String userId = request.getHeader("userId");
        String userOldToken = request.getHeader("userToken");
        if(StringUtils.isNotBlank(userId) && StringUtils.isNotBlank(userOldToken)){
            String userTokenKey = USER_REDIS_TOKEN + ":" + userId;
            String userToken = redisOperator.getValue(userTokenKey);
            // 用户有token,但最新token为空,说明登录状态过期
            if(StringUtils.isBlank(userToken)){
                returnErrorResponse(response,QiqvJSONResult.noAuth("登录过期,请重新登录"));
                return false;
            }
            // 两个token不一致,可能是恶意用户乱填token
            if(!userOldToken.equals(userToken)){
                returnErrorResponse(response,QiqvJSONResult.noAuth("无效token,请重新登录"));
                return false;
            }
        }else{
            System.out.println("该用户没有登录");
            returnErrorResponse(response,QiqvJSONResult.noAuth("请登录后再操作"));
            return false;
        }
        return true;
    }

    public void returnErrorResponse(HttpServletResponse response, QiqvJSONResult qiqvJSONResult) throws IOException {
        OutputStream outputStream = null ;
        try {
            response.setContentType("application/json");
            response.setCharacterEncoding("utf-8");
            outputStream = response.getOutputStream();
            outputStream.write(JSONUtils.objectToJson(qiqvJSONResult).getBytes("UTF-8"));
            outputStream.flush();
        } catch (IOException e) {
            e.printStackTrace();
        }finally {
            if(outputStream != null){
                outputStream.close();
            }
        }
    }


}

解释一下思路:

  1. 使用userId作为用户登录的唯一key值,UUID作为value。存放在redis中,30min后过期
  2. 由于请求还未到controller,所以转换结果的时候需要手动转一下json
2、注册拦截器
package com.qiqv.music.config;

import com.qiqv.music.controller.interceptor.MiniInterceptor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurerAdapter;

import java.util.Arrays;
import java.util.List;

@Configuration
public class WebMvcConfig extends WebMvcConfigurerAdapter {

    @Bean
    public MiniInterceptor miniInterceptor(){
        return new MiniInterceptor();
    }

 
    /**
     * 设置拦截的url路径
     *  暂时只针对前端用户评论、收藏、评分功能进行拦截
     * @param registry
     */
    @Override
    public void addInterceptors(InterceptorRegistry registry){
        List listOfVerify = Arrays.asList("/consumer/**","/rank/rateSongList","/collect/**","/comment/**");
        List listOfExc = Arrays.asList("/consumer/login","/consumer/queryUserById","/consumer/getAllConsumer","/collect/getUserCollect","/comment/query**","/comment/allComment");
        registry.addInterceptor(miniInterceptor()).addPathPatterns(listOfVerify)
                .excludePathPatterns(listOfExc);
        super.addInterceptors(registry);
    }
}

这里的话,针对需要拦截的路径和需要放行的路径进行配置就行
关于redisTemple的引入这里就不再赘述。
到这里为止,前后端的token就都做完了,后面就再讲讲前端的一些其他思路吧
对于登录状态的判断,前端可以在router.foreach上对路由进行状态判定,从而实现页面程度的拦截(具体可以参考最后的参考文章2)

隐藏的小坑:
跨域问题

在使用拦截器后,会发现前端部分请求会无法正常到达后端,百度后发现是因为axios发送正式请求前会先发送一个嗅探请求,而嗅探请求是不携带我们封装的header的,所以会导致部分请求会无法成功,解决的方式有很多种,这里的话是选择了在后端去直接处理

参考文章
1、SpringBoot加了拦截器后出现的跨域问题解析
https://blog.csdn.net/mrkorbin/article/details/104066979
2、Vue项目中实现用户登录及token验证
https://www.cnblogs.com/web-record/p/9876916.html

posted @ 2020-12-17 09:50  moutory  阅读(135)  评论(0编辑  收藏  举报  来源