前后端分离场景下的用户登录玩法Sa-token框架使用
两种方案的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失效的都放行到登录拦截器* */@Overridepublic 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*/@Overridepublic 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 {@ResourceStringRedisTemplate stringRedisTemplate;@Overridepublic boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {//前端请求时带上authorizationString token = request.getHeader("authorization");if (StringUtils.isBlank(token)) {//未登录,直接放行,由登录拦截器拦截return true;}//从redis获取tokenString 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;}@Overridepublic 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 {@ResourceLoginInterceptor loginInterceptor;@ResourceRefreshTokenInterceptor refreshTokenInterceptor;@Overrideprotected 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测试@Overrideprotected 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; // 计算过期时间,单位 minconst 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'); // 如果过期了,删除 loginUserlocalStorage.removeItem('loginUser'); // 如果过期了,删除 tokensetTimeout(() => {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 {@Overridepublic 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;}// 移除用户,防止内存泄漏!!!@Overridepublic void afterCompletion(HttpServletRequest request, HttpServletResponse response,Object handler, Exception ex) {UserContext.removeUser();}}
注册该拦截器
@Slf4j
@Configuration
public class SaTokenConfigure implements WebMvcConfigurer {@ResourceSaTokenInterceptor saTokenInterceptor;/*** 注册 Sa-Token 拦截器打开注解鉴权功能*/@Overridepublic 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 全局过滤器]*/@Beanpublic 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*/@Beanpublic 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("*");//是否发送 Cookieconfig.setAllowCredentials(true);//放行哪些请求方式config.addAllowedMethod("*");//放行哪些原始请求头部信息config.addAllowedHeader("*");//暴露哪些头部信息//config.addExposedHeader("*");//2. 添加映射路径UrlBasedCorsConfigurationSource corsConfigurationSource = new UrlBasedCorsConfigurationSource();corsConfigurationSource.registerCorsConfiguration("/**", config);//3. 返回新的CorsFilterreturn new CorsFilter(corsConfigurationSource);}/*** 解决SaTokenContext 上下文尚未初始化的问题* 参考: https://gitee.com/dromara/sa-token/issues/IC4XFE* @return*/@Beanpublic 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;}}