前后端分离场景下的用户登录玩法&Sa-token框架使用

本文对比了两种基于Redis的Token认证方案。方案一采用传统双拦截器设计,前端需自行检查Token有效期并加密存储于localStorage,后端通过LoginInterceptor和RefreshTokenInterceptor分别处理登录校验和Token刷新,存在前后端有效期同步问题。方案二使用Sa-Token框架,实现多端登录隔离和自动续期,通过单一SaTokenInterceptor即可完成认证,内置Account-Session和Token-Session机制支持多终端独立管理,简化了开发流程

两种方案的 token、用户登录信息都存储在 redis 中!!

方案一

该方案是前端把 token 和 token 有效期一起加密存储到浏览器的 localStorage 中,每次请求时调用前端的 getTokenIsExpiry()获取 token 并检查 token 是否过期,过期则 remove 并跳转登录页,这样前端有个问题就是前端也要知道 token 的有效期,需要和后端的 token 有效期保持一致,而后端则提供两个拦截器,分别用来刷新 token、判断是否是登录用户,这个参考了黑马外卖。

后端

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
/**
 * @Author:懒大王Smile
 * @Date: 2024/9/14
 * @Time: 18:07
 * @Description: 登录拦截器
 */
@Component
public class LoginInterceptor implements HandlerInterceptor {

  /*
   * authorization为空和redis的token失效的都放行到登录拦截器
   * */
  @Override
  public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler){

    String requestURI = request.getRequestURI();
    if (requestURI.contains("/api/favicon.ico") || requestURI.contains("/api-docs") || requestURI.contains("/error")) {
      return true;
    }

    if (UserContext.getUser() == null) {
      response.setStatus(401);
      //response.setHeader("登录拦截器:","该请求被拦截,请登录!");
      throw new BusinessException(ErrorCode.NOT_LOGIN_ERROR, ErrorInfo.NOT_LOGIN_ERROR);
    }
    return true;
  }

  /**
   * 目标 Controller 的方法执行完并且返回结果之后,视图解析器渲染视图之前执行。
   * @param request
   * @param response
   * @param handler
   * @param ex
   * @throws Exception
   */
  @Override
  public void afterCompletion(HttpServletRequest request, HttpServletResponse response,
      Object handler, Exception ex) throws Exception {
    HandlerInterceptor.super.afterCompletion(request, response, handler, ex);
  }
}


/**
 * @Author:懒大王Smile
 * @Date: 2024/9/14
 * @Time: 18:24
 * @Description: 该拦截器只负责刷新token(redis共享session),不负责拦截
 */

@Component
public class RefreshTokenInterceptor implements HandlerInterceptor {

  @Resource
  StringRedisTemplate stringRedisTemplate;

  @Override
  public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
    //前端请求时带上authorization
    String token = request.getHeader("authorization");
    if (StringUtils.isBlank(token)) {
      //未登录,直接放行,由登录拦截器拦截
      return true;
    }

    //从redis获取token
    String tokenKey = Common.LOGIN_TOKEN_KEY + token;
    Map<Object, Object> map = stringRedisTemplate.opsForHash().entries(tokenKey);
    if (map.isEmpty()) {
      //redis中存储的登录态已失效,放行,让登录拦截器拦截
      return true;
    }
    LoginUserVO loginUserVO = BeanUtil.fillBeanWithMap(map, new LoginUserVO(), false);
    //将用户信息保存到ThreadLocal中
    UserContext.saveUser(loginUserVO);

    //刷新redis的token有效期
    stringRedisTemplate.expire(tokenKey, Common.LOGIN_TOKEN_TTL, TimeUnit.MINUTES);
    return true;
  }

  @Override
  public void afterCompletion(HttpServletRequest request, HttpServletResponse response,
      Object handler, Exception ex) {
    //// 移除用户,防止内存泄漏!!!
    UserContext.removeUser();
  }
}


/**
 * @Author:懒大王Smile
 * @Date: 2024/9/18
 * @Time: 16:48
 * @Description: 拦截器配置类,注册拦截器
 */
@Component
@Slf4j
public class InterceptorsConfig extends WebMvcConfigurationSupport {

  @Resource
  LoginInterceptor loginInterceptor;

  @Resource
  RefreshTokenInterceptor refreshTokenInterceptor;

  @Override
  protected void addInterceptors(InterceptorRegistry registry) {

    log.info("注册自定义拦截器");
    registry.addInterceptor(refreshTokenInterceptor)
        .addPathPatterns("/**")
        .excludePathPatterns(
            "/doc.html/**",
            "/swagger-resources/**",
            "/webjars/**",
            "/ai/**"
        ).order(0);
        //  order越小,优先级越高

    registry.addInterceptor(loginInterceptor)
        .addPathPatterns("/**")
        .excludePathPatterns(
            "/webjars/**",
            "/doc.html/**",
            "/swagger-resources/**",
            "/v3/api-docs/",
            "/api/favicon.ico"
        );
  }

  //没有该配置将无法使用swagger API测试
  @Override
  protected void addResourceHandlers(ResourceHandlerRegistry registry) {
    registry.addResourceHandler("/**")
        .addResourceLocations("classpath:/static/")
        .addResourceLocations("classpath:/META-INF/resources/");
  }
}

前端

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
requestConfig.ts
//前端配置请求拦截器,实现在每个请求发出前为请求头添加token
requestInterceptors: [
    (config: any) => {
      const token = getTokenIsExpiry();
      if (token) {
        config.headers['authorization'] = token;
      }
      return config;
    },
  ]

utils.ts

// 存储token和登录态
export const setTokenWithExpiry = (loginUser: API.LoginUserVO) => {
  const encryptLoginUser = encrypt(loginUser);
  localStorage.setItem('loginUser', encryptLoginUser); // 存储 loginUser 和过期时间

  const expiryTime = new Date().getTime() + TokenTTL * 60 * 1000; // 计算过期时间,单位 min
  const item = {
    token: loginUser.token,
    expiry: expiryTime,
  };
  const encryptToken = encrypt(item);
  localStorage.setItem('authorization', encryptToken);
};

// 获取 token 并检查是否过期,如果过期就删除
export const getTokenIsExpiry = () => {
  const encryptToken = localStorage.getItem('authorization');
  if (!encryptToken) {
    return null; // 如果没有 token,返回 null
  }
  const tokenObj = decrypt(encryptToken);
  const currentTime = new Date().getTime();
  if (currentTime > tokenObj.expiry) {
    localStorage.removeItem('authorization'); // 如果过期了,删除 loginUser
    localStorage.removeItem('loginUser'); // 如果过期了,删除 token
    setTimeout(() => {
      window.location.reload();
    }, 400);
    history.replace('/home');
    message.info('登陆凭证过期,请重新登录');
  }
  return tokenObj.token; // 如果没有过期,返回 token
};

方案二

后端使用 sa-token 框架Sa-Token实现用户登录注销、鉴权等操作,可以方便的集成 redis

Sa-token 框架

如图是3343@qq.com账号连续登录三次,redis 中生成的 3 个 token 及一个 account-session,此时仅作登陆操作

“authorization:login:session:3343@qq.com"内容如下:

可以看到"terminalList"中记录了 3 次登录产生的详细的 token 信息

{
    "@class": "cn.dev33.satoken.session.SaSession",
    "id": "authorization:login:session:3343@qq.com",
    "type": "Account-Session",
    "loginType": "login",
    "loginId": "3343@qq.com",
    "token": null,
    "historyTerminalCount": 3,
    "createTime": 1751087763506,
    "dataMap": {
        "@class": "java.util.concurrent.ConcurrentHashMap"
    },
    "terminalList": [
        "java.util.Vector",
        [
            {
                "@class": "cn.dev33.satoken.session.SaTerminalInfo",
                "index": 1,
                "tokenValue": "9d3e2b34-a5ad-4059-bdf4-4add0c370ca0",
                "deviceType": "DEF",
                "deviceId": null,
                "extraData": null,
                "createTime": 1751087763575
            },
            {
                "@class": "cn.dev33.satoken.session.SaTerminalInfo",
                "index": 2,
                "tokenValue": "4a740c99-071c-4512-af02-a9519e058b4d",
                "deviceType": "DEF",
                "deviceId": null,
                "extraData": null,
                "createTime": 1751087826615
            },
            {
                "@class": "cn.dev33.satoken.session.SaTerminalInfo",
                "index": 3,
                "tokenValue": "36d7a224-1f8e-4605-84d7-cb5ecf594018",
                "deviceType": "DEF",
                "deviceId": null,
                "extraData": null,
                "createTime": 1751087850414
            }
        ]
    ]
}

“authorization:login:token:4a740c99-071c-4512-af02-a9519e058b4d"内容如下:

> 然后调用 StpUtil.getTokenSession(),此时就会生成一个 token-session

{
    "@class": "cn.dev33.satoken.session.SaSession",
    "id": "authorization:login:token-session:36d7a224-1f8e-4605-84d7-cb5ecf594018",
    "type": "Token-Session",
    "loginType": "login",
    "loginId": null,
    "token": "36d7a224-1f8e-4605-84d7-cb5ecf594018",
    "historyTerminalCount": 0,
    "createTime": 1751088437477,
    "dataMap": {
        "@class": "java.util.concurrent.ConcurrentHashMap"
    },
    "terminalList": [
        "java.util.Vector",
        [

        ]
    ]
}

发现 token-session 和 account-session 结构相同,因为它们都出自同一个 SaSession 类

可知在 Sa-Token 框架中,session 分别三种,我这里只关注 account 和 token 的 session,前面提到在使用同一个账号连续登陆 3 次时只生成了 account-session,其中记录了三次的登录的 token,那么这就可以实现了同一账号多端登录 ,每个端的token 隔离,比如同时在 PC 和 IOS 端登录,如果 token 不隔离(token 共享),当在其中一端注销登录时,另一端也会被迫注销登录,显然不合常理,而如果实现的 token 隔离,每个端都有不同的 token,那么这就不会出现另一端被迫注销的情况。所以说 account-session 记录了同一账号多端登录的 token 信息,而 token-session 则记录了该账号在某一端的 token 信息,更为详细。

sa-token 设置有效期

在 yml 配置 timeout,单位是 s,同一账号先后多端登录,token 过期后先删除 token-session,待该账号下所有 token 全部过期后才删除 account-session。

sa-token 自动续期

SaTokenConfig.java,在 yml 配置 autoRenew 即可开启自动续期,每次要续期时直接或间接调用 getLoginId()即可。

后端

仅需一个拦截器即可,不再需要方案一的两个拦截器。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
@Slf4j
@Component
public class SaTokenInterceptor implements HandlerInterceptor {

  @Override
  public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
    String requestURI = request.getRequestURI();
    if (requestURI.contains("/api/webjars") || requestURI.contains("/api/favicon.ico") || requestURI.contains("/api-docs") || requestURI.contains("/error")) {
      return true;
    }

    //刷新token有效期(这一步已经判断了名为authorization的token是否是真实有效的,如果是伪造或过期的token则不会刷新token,报错)
    Long userId;
    try {
      userId = Long.valueOf(StpUtil.getLoginId().toString());
    } catch (Exception e) {
      if(requestURI.contains("/ai")){
        return true;
      }
      throw new RuntimeException(e);
    }

    //虽然每次可以从stpUtil.getLoginId()获取userId,但是这样要读redis,会对其造成压力,因此这里取出来放到userContext,用的时候从userContext取
    UserContext.saveUser(userId);
    //角色校验
    if(requestURI.contains("/admin")){
      StpUtil.checkRole(UserRoleEnum.ADMIN.getRole());
    }
    return true;
  }

  // 移除用户,防止内存泄漏!!!
  @Override
  public void afterCompletion(HttpServletRequest request, HttpServletResponse response,
      Object handler, Exception ex) {
    UserContext.removeUser();
  }

}

注册该拦截器

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
@Slf4j
@Configuration
public class SaTokenConfigure implements WebMvcConfigurer {

  @Resource
  SaTokenInterceptor saTokenInterceptor;

  /**
   * 注册 Sa-Token 拦截器打开注解鉴权功能
   */
  @Override
  public void addInterceptors(InterceptorRegistry registry) {
    // 注册 Sa-Token 拦截器打开注解鉴权功能
    log.info("注册自定义拦截器");
    registry.addInterceptor(saTokenInterceptor)
        .addPathPatterns("/**")
        .excludePathPatterns(
            "/user/login",
            "/user/register",
            "/user/getUserInfo/{uid}",
            "/user/sendRegisterCode",
            "/user/find/{userName}",
            "/user/userInfoData",
            "/passage/otherPassages/{uid}",
            "/passage/topCollects",
            "/passage/content/{uid}/{pid}",
            "/passage/homePassageList",
            "/passage/search",
            "/passage/passageInfo/{pid}",
            "/passage/topPassages",
            "/comment/getCommentByCursor",
            "/category/getCategories",
            "/tag/getRandomTags",
            "/doc.html/**"
        );

  }

  /**
   * 注册 [Sa-Token 全局过滤器]
   */
  @Bean
  public SaServletFilter getSaServletFilter() {
    return new SaServletFilter()

        // 指定 [拦截路由] 与 [放行路由]
        .addInclude("/**")
        // 认证函数: 每次请求执行
        .setAuth(obj -> {
           SaManager.getLog().info("----- 请求path={},authorization={}", SaHolder.getRequest().getRequestPath(), StpUtil.getTokenValue());
          // 权限校验 -- 不同模块认证不同权限
          //		这里你可以写和拦截器鉴权同样的代码,不同点在于:
          // 		校验失败后不会进入全局异常组件,而是进入下面的 .setError 函数
//          SaRouter.match("/admin/**", r -> StpUtil.checkPermission("admin"));
        })

        // 异常处理函数:每次认证函数发生异常时执行此函数
        .setError(e -> {
          log.warn("---------- sa-token全局异常 ");
          return SaResult.error(e.getMessage());
        })

        // 前置函数:在每次认证函数之前执行(BeforeAuth 不受 includeList 与 excludeList 的限制,所有请求都会进入)
        .setBeforeAuth(r -> {
          // ---------- 设置一些安全响应头 ----------
          SaHolder.getResponse()
              // 服务器名称
              .setServer("sa-server")
              // 是否可以在iframe显示视图: DENY=不可以 | SAMEORIGIN=同域下可以 | ALLOW-FROM uri=指定域名下可以
              .setHeader("X-Frame-Options", "SAMEORIGIN")
              // 是否启用浏览器默认XSS防护: 0=禁用 | 1=启用 | 1; mode=block 启用, 并在检查到XSS攻击时,停止渲染页面
              .setHeader("X-XSS-Protection", "1; mode=block")
              // 禁用浏览器内容嗅探
              .setHeader("X-Content-Type-Options", "nosniff")
          ;
        })
        ;
  }

  /**
   * 解决cors跨域
   * @return
   */
  @Bean
  public CorsFilter corsFilter() {
    //1. 添加 CORS配置信息
    CorsConfiguration config = new CorsConfiguration();

    //放行哪些原始域
    //带上这个会报错
//           config.addAllowedOrigin("localhost:8000");
//         When allowCredentials is true, allowedOrigins cannot contain the special value "*" since that cannot be set on the "Access-Control-Allow-Origin" response header. To allow credentials to a set of origins, list them explicitly or consider using "allowedOriginPatterns" instead.

    config.addAllowedOriginPattern("*");
    //是否发送 Cookie
    config.setAllowCredentials(true);
    //放行哪些请求方式
    config.addAllowedMethod("*");
    //放行哪些原始请求头部信息
    config.addAllowedHeader("*");
    //暴露哪些头部信息
    //config.addExposedHeader("*");
    //2. 添加映射路径
    UrlBasedCorsConfigurationSource corsConfigurationSource = new UrlBasedCorsConfigurationSource();
    corsConfigurationSource.registerCorsConfiguration("/**", config);
    //3. 返回新的CorsFilter
    return new CorsFilter(corsConfigurationSource);
  }

  /**
   * 解决SaTokenContext 上下文尚未初始化的问题
   * 参考: https://gitee.com/dromara/sa-token/issues/IC4XFE
   * @return
   */
  @Bean
  public FilterRegistrationBean saTokenContextFilterForJakartaServlet() {
    FilterRegistrationBean bean = new FilterRegistrationBean<>(new SaTokenContextFilterForJakartaServlet());
    // 配置 Filter 拦截的 URL 模式
    bean.addUrlPatterns("/*");
    // 设置 Filter 的执行顺序,数值越小越先执行
    bean.setOrder(Ordered.HIGHEST_PRECEDENCE);
    bean.setAsyncSupported(true);
    bean.setDispatcherTypes(EnumSet.of(DispatcherType.ASYNC, DispatcherType.REQUEST));
    return bean;
  }

}

前端

comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计