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

发布于:2025-06-30 ⋅ 阅读:(20) ⋅ 点赞:(0)

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

方案一 

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

后端

/**
 * @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/");
  }
}

前端

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()即可。

后端

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

@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();
  }

}

注册该拦截器

@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;
  }

}

前端