两种方案的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;
}
}