|
|
|
|
|
/*
|
|
|
|
|
|
* auth.js
|
|
|
|
|
|
* Copyright (C) 2025 veypi <i@veypi.com>
|
|
|
|
|
|
*
|
|
|
|
|
|
* Distributed under terms of the MIT license.
|
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
|
|
class TokenService {
|
|
|
|
|
|
#url = '/'
|
|
|
|
|
|
constructor() {
|
|
|
|
|
|
this.tokenKey = 'access';
|
|
|
|
|
|
this.refreshTokenKey = 'refresh';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
check(domain, id, level) {
|
|
|
|
|
|
let body = this.body();
|
|
|
|
|
|
if (!body || !body.access) {
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
for (let a of body.access) {
|
|
|
|
|
|
if (a.name === domain && (a.tid === '' || a.tid === id) && a.level >= level) {
|
|
|
|
|
|
return true
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
setBaseUrl(url) {
|
|
|
|
|
|
this.#url = url;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
setToken(token) {
|
|
|
|
|
|
localStorage.setItem(this.tokenKey, token);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
getToken() {
|
|
|
|
|
|
return localStorage.getItem(this.tokenKey);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
toJSON() {
|
|
|
|
|
|
return this.getToken()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
toString() {
|
|
|
|
|
|
return this.getToken()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
setRefreshToken(refreshToken) {
|
|
|
|
|
|
localStorage.setItem(this.refreshTokenKey, refreshToken);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
getRefreshToken() {
|
|
|
|
|
|
return localStorage.getItem(this.refreshTokenKey);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
clearToken() {
|
|
|
|
|
|
localStorage.removeItem(this.tokenKey);
|
|
|
|
|
|
localStorage.removeItem(this.refreshTokenKey);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
hasToken() {
|
|
|
|
|
|
return !!this.getToken();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
parseToken(token) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
if (!token || typeof token !== 'string') return null;
|
|
|
|
|
|
const base64Url = token.split('.')[1];
|
|
|
|
|
|
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
|
|
|
|
|
|
return JSON.parse(window.atob(base64));
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.info('Token解析失败:', error);
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
__cache = null
|
|
|
|
|
|
body() {
|
|
|
|
|
|
if (!this.__cache) {
|
|
|
|
|
|
this.__cache = this.parseToken(this.getToken());
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!this.__cache) {
|
|
|
|
|
|
this.__cache = this.parseToken(this.getRefreshToken());
|
|
|
|
|
|
}
|
|
|
|
|
|
return this.__cache
|
|
|
|
|
|
}
|
|
|
|
|
|
logout(to, querys) {
|
|
|
|
|
|
this.clearToken();
|
|
|
|
|
|
let url = new URL(this.#url, window.location.origin)
|
|
|
|
|
|
let redirect = to || window.location.pathname
|
|
|
|
|
|
url.searchParams.set('redirect', redirect)
|
|
|
|
|
|
for (let key in querys) {
|
|
|
|
|
|
url.searchParams.set(key, querys[key]);
|
|
|
|
|
|
}
|
|
|
|
|
|
url.pathname += '/login'
|
|
|
|
|
|
location.href = url.toString()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async refresh() {
|
|
|
|
|
|
const refreshToken = this.getRefreshToken();
|
|
|
|
|
|
if (!refreshToken) {
|
|
|
|
|
|
this.logout()
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
try {
|
|
|
|
|
|
let data = await fetch(this.#url + '/api/token', {
|
|
|
|
|
|
method: 'post',
|
|
|
|
|
|
headers: { 'Content-Type': 'application/json' },
|
|
|
|
|
|
body: JSON.stringify({ refresh: refreshToken })
|
|
|
|
|
|
}).then(res => {
|
|
|
|
|
|
if (res.status !== 200) {
|
|
|
|
|
|
throw new Error(`Token刷新失败,状态码: ${res.status}`);
|
|
|
|
|
|
}
|
|
|
|
|
|
return res.text()
|
|
|
|
|
|
})
|
|
|
|
|
|
this.__cache = null; // 清除缓存
|
|
|
|
|
|
this.setToken(data);
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
console.error('Token刷新失败:', e);
|
|
|
|
|
|
this.clearToken()
|
|
|
|
|
|
logout();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
isExpired() {
|
|
|
|
|
|
const decoded = this.body();
|
|
|
|
|
|
if (!decoded) return true;
|
|
|
|
|
|
|
|
|
|
|
|
const currentTime = Date.now() / 1000;
|
|
|
|
|
|
return decoded.exp < currentTime;
|
|
|
|
|
|
}
|
|
|
|
|
|
wrapAxios(instance) {
|
|
|
|
|
|
|
|
|
|
|
|
// 定义一个标志,用于防止在刷新令牌时发送多个刷新请求
|
|
|
|
|
|
let isRefreshing = false;
|
|
|
|
|
|
// 定义一个队列,用于存储在刷新令牌期间失败的请求
|
|
|
|
|
|
let failedQueue = [];
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 处理等待队列中的请求。
|
|
|
|
|
|
* 刷新令牌成功后,将队列中的请求重新发送;刷新失败则拒绝这些请求。
|
|
|
|
|
|
* @param {Error|null} error - 如果刷新令牌失败,则为错误对象。
|
|
|
|
|
|
* @param {string|null} token - 刷新成功后获取的新令牌。
|
|
|
|
|
|
*/
|
|
|
|
|
|
const processQueue = (error, token = null) => {
|
|
|
|
|
|
failedQueue.forEach(prom => {
|
|
|
|
|
|
if (error) {
|
|
|
|
|
|
// 刷新失败,拒绝队列中的所有请求
|
|
|
|
|
|
prom.reject(error);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// 刷新成功,使用新令牌解决队列中的所有请求
|
|
|
|
|
|
prom.resolve(token);
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
// 清空队列
|
|
|
|
|
|
failedQueue = [];
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 请求拦截器:在发送请求前添加认证令牌
|
|
|
|
|
|
instance.interceptors.request.use(config => {
|
|
|
|
|
|
const token = this.getToken();
|
|
|
|
|
|
if (token) {
|
|
|
|
|
|
config.headers.Authorization = `Bearer ${token}`;
|
|
|
|
|
|
}
|
|
|
|
|
|
return config;
|
|
|
|
|
|
}, error => {
|
|
|
|
|
|
// 对请求错误做些什么
|
|
|
|
|
|
return Promise.reject(error);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
let that = this
|
|
|
|
|
|
// 响应拦截器:处理响应数据,特别是针对 401 状态码进行令牌刷新和重试
|
|
|
|
|
|
instance.interceptors.response.use((response) => {
|
|
|
|
|
|
// 任何 2xx 范围内的状态码都会触发此函数
|
|
|
|
|
|
// 这里可以添加其他全局的成功响应处理逻辑
|
|
|
|
|
|
return response;
|
|
|
|
|
|
}, async function(error) {
|
|
|
|
|
|
// 任何超出 2xx 范围的状态码都会触发此函数
|
|
|
|
|
|
const originalRequest = error.config;
|
|
|
|
|
|
|
|
|
|
|
|
// 检查错误响应状态码是否为 401 (未授权)
|
|
|
|
|
|
// 并且确保这不是一个已经重试过的请求 (通过 originalRequest._retry 标记)
|
|
|
|
|
|
|
|
|
|
|
|
if (error.response && error.response.status === 401 && !originalRequest.noretry) {
|
|
|
|
|
|
|
|
|
|
|
|
// 统计该请求的重试次数
|
|
|
|
|
|
originalRequest.__retryCount = originalRequest.__retryCount || 0;
|
|
|
|
|
|
originalRequest.__retryCount++;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// 如果重试次数超过 3 次,则不再重试,直接跳转到登录页
|
|
|
|
|
|
if (originalRequest.__retryCount >= 3) {
|
|
|
|
|
|
// that.logout()
|
|
|
|
|
|
// 拒绝原始请求的 Promise,停止后续处理
|
|
|
|
|
|
return Promise.reject(error);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 如果当前正在进行令牌刷新
|
|
|
|
|
|
if (isRefreshing) {
|
|
|
|
|
|
// 将当前失败的请求添加到队列中,等待新令牌
|
|
|
|
|
|
return new Promise(resolve => {
|
|
|
|
|
|
failedQueue.push({ resolve, reject: (err) => { throw err; } });
|
|
|
|
|
|
}).then(token => {
|
|
|
|
|
|
// 刷新成功后,使用新令牌更新请求头
|
|
|
|
|
|
originalRequest.headers.Authorization = `Bearer ${token}`;
|
|
|
|
|
|
// 重新发送原始请求
|
|
|
|
|
|
return instance(originalRequest);
|
|
|
|
|
|
}).catch(err => {
|
|
|
|
|
|
// 如果队列中的请求被拒绝,则抛出错误
|
|
|
|
|
|
return Promise.reject(err);
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 如果没有正在进行令牌刷新,则设置标志为 true,开始刷新
|
|
|
|
|
|
isRefreshing = true;
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
// 发送请求来刷新令牌
|
|
|
|
|
|
await that.refresh();
|
|
|
|
|
|
const newToken = that.getToken();
|
|
|
|
|
|
|
|
|
|
|
|
// 更新原始请求的 Authorization 头为新的令牌
|
|
|
|
|
|
originalRequest.headers.Authorization = `Bearer ${newToken}`;
|
|
|
|
|
|
// 处理等待队列中的所有请求,用新的令牌重新发送
|
|
|
|
|
|
processQueue(null, newToken);
|
|
|
|
|
|
// 重新发送最初导致 401 错误的请求
|
|
|
|
|
|
return instance(originalRequest);
|
|
|
|
|
|
} catch (refreshError) {
|
|
|
|
|
|
// 如果刷新令牌本身也失败了 (例如,refresh token 已过期)
|
|
|
|
|
|
// 清除本地令牌
|
|
|
|
|
|
// 拒绝等待队列中的所有请求
|
|
|
|
|
|
processQueue(refreshError);
|
|
|
|
|
|
// that.clearToken();
|
|
|
|
|
|
that.logout();
|
|
|
|
|
|
// 拒绝原始请求的 Promise
|
|
|
|
|
|
return Promise.reject(refreshError);
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
// 无论成功或失败,最后都要将刷新标志重置为 false
|
|
|
|
|
|
isRefreshing = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
// 对于其他类型的错误,或不是需要重试的 401 错误,直接拒绝 Promise
|
|
|
|
|
|
return Promise.reject(error);
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export default new TokenService();
|