You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
OneAuth/ui/token.js

227 lines
6.8 KiB
JavaScript

7 months ago
/*
* auth.js
* Copyright (C) 2025 veypi <i@veypi.com>
*
* Distributed under terms of the MIT license.
*/
class TokenService {
#url = '/'
7 months ago
constructor() {
this.tokenKey = 'access';
this.refreshTokenKey = 'refresh';
}
setBaseUrl(url) {
this.#url = url;
}
7 months ago
setToken(token) {
localStorage.setItem(this.tokenKey, token);
}
getToken() {
return localStorage.getItem(this.tokenKey);
}
toJSON() {
return this.getToken()
}
toString() {
return this.getToken()
}
7 months ago
setRefreshToken(refreshToken) {
localStorage.setItem(this.refreshTokenKey, refreshToken);
}
getRefreshToken() {
return localStorage.getItem(this.refreshTokenKey);
}
clearToken() {
7 months ago
localStorage.removeItem(this.tokenKey);
localStorage.removeItem(this.refreshTokenKey);
}
hasToken() {
return !!this.getToken();
}
parseToken(token) {
try {
3 months ago
if (!token || typeof token !== 'string') return null;
7 months ago
const base64Url = token.split('.')[1];
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
return JSON.parse(window.atob(base64));
} catch (error) {
3 months ago
console.warn('Token解析失败:', error);
7 months ago
return null;
}
}
__cache = null
body() {
if (!this.__cache) {
this.__cache = this.parseToken(this.getToken());
}
if (!this.__cache) {
this.__cache = this.parseToken(this.getRefreshToken());
}
7 months ago
return this.__cache
}
3 months ago
logout(to, querys) {
this.clearToken();
3 months ago
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()
7 months ago
}
async refresh() {
7 months ago
const refreshToken = this.getRefreshToken();
if (!refreshToken) {
3 months ago
this.logout()
7 months ago
return;
}
try {
let data = await fetch(this.#url + '/api/token', {
7 months ago
method: 'post',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refresh: refreshToken })
3 months ago
}).then(res => res.text())
3 months ago
this.__cache = null; // 清除缓存
this.setToken(data);
7 months ago
} catch (e) {
console.error('Token刷新失败:', e);
this.clearToken()
3 months ago
logout();
7 months ago
}
}
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();
7 months ago
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.__retryCount = originalRequest.__retryCount || 0;
originalRequest.__retryCount++;
3 months ago
// 如果重试次数超过 3 次,则不再重试,直接跳转到登录页
if (originalRequest.__retryCount >= 3) {
3 months ago
// 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);
});
7 months ago
}
// 如果没有正在进行令牌刷新,则设置标志为 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();
3 months ago
that.logout();
// 拒绝原始请求的 Promise
return Promise.reject(refreshError);
} finally {
// 无论成功或失败,最后都要将刷新标志重置为 false
isRefreshing = false;
7 months ago
}
}
// 对于其他类型的错误,或不是需要重试的 401 错误,直接拒绝 Promise
return Promise.reject(error);
});
7 months ago
}
}
export default new TokenService();