新登录验证机制

This commit is contained in:
mtvpls
2026-01-24 15:31:13 +08:00
parent fadec0afe4
commit 49484d0121
8 changed files with 738 additions and 56 deletions
+80
View File
@@ -0,0 +1,80 @@
/* eslint-disable no-console */
import { TOKEN_CONFIG, verifyRefreshToken } from './refresh-token';
// 生成签名
export async function generateSignatureForMiddleware(
data: string,
secret: string
): Promise<string> {
const encoder = new TextEncoder();
const keyData = encoder.encode(secret);
const messageData = encoder.encode(data);
const key = await crypto.subtle.importKey(
'raw',
keyData,
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign']
);
const signature = await crypto.subtle.sign('HMAC', key, messageData);
return Array.from(new Uint8Array(signature))
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
}
// 刷新 Access Token
export async function refreshAccessToken(
username: string,
role: string,
tokenId: string,
refreshToken: string,
refreshExpires: number
): Promise<string | null> {
// 验证 Refresh Token
const isValid = await verifyRefreshToken(username, tokenId, refreshToken);
if (!isValid) {
console.log(`Refresh token invalid for ${username}:${tokenId}`);
return null;
}
const now = Date.now();
const authData = {
username,
role,
timestamp: now, // 新的 Access Token 时间戳
tokenId,
refreshToken,
refreshExpires
};
// 生成新的签名
const dataToSign = JSON.stringify({
username,
role,
timestamp: now
});
const signature = await generateSignatureForMiddleware(
dataToSign,
process.env.PASSWORD || ''
);
authData.signature = signature;
console.log(`Refreshed access token for ${username}`);
return encodeURIComponent(JSON.stringify(authData));
}
// 检查是否需要续期
export function shouldRenewToken(timestamp: number): boolean {
const age = Date.now() - timestamp;
const remaining = TOKEN_CONFIG.ACCESS_TOKEN_AGE - age;
return remaining < TOKEN_CONFIG.RENEWAL_THRESHOLD && remaining > 0;
}
+247
View File
@@ -0,0 +1,247 @@
/* eslint-disable no-console */
import { getStorage } from './db';
// Token 配置
export const TOKEN_CONFIG = {
ACCESS_TOKEN_AGE: 4 * 60 * 60 * 1000, // 4 小时
REFRESH_TOKEN_AGE: 60 * 24 * 60 * 60 * 1000, // 60 天
RENEWAL_THRESHOLD: 10 * 60 * 1000, // 剩余 10 分钟时自动续期
};
interface TokenData {
token: string;
deviceInfo: string;
createdAt: number;
expiresAt: number;
lastUsed: number;
}
// 生成随机 Token ID
export function generateTokenId(): string {
const array = new Uint8Array(16);
crypto.getRandomValues(array);
return Array.from(array, byte => byte.toString(16).padStart(2, '0')).join('');
}
// 生成随机 Refresh Token
export function generateRefreshToken(): string {
const array = new Uint8Array(32);
crypto.getRandomValues(array);
return Array.from(array, byte => byte.toString(16).padStart(2, '0')).join('');
}
// 存储 Refresh Token(使用 Redis Hash
export async function storeRefreshToken(
username: string,
tokenId: string,
tokenData: TokenData
): Promise<void> {
const hashKey = `user_tokens:${username}`;
const storage = getStorage();
if (!storage || typeof (storage as any).adapter?.hSet !== 'function') {
console.warn('Redis Hash not supported, skipping token storage');
return;
}
try {
await (storage as any).adapter.hSet(
hashKey,
tokenId,
JSON.stringify(tokenData)
);
console.log(`Stored refresh token for ${username}:${tokenId}`);
} catch (error) {
console.error('Failed to store refresh token:', error);
throw error;
}
}
// 验证 Refresh Token
export async function verifyRefreshToken(
username: string,
tokenId: string,
refreshToken: string
): Promise<boolean> {
const hashKey = `user_tokens:${username}`;
const storage = getStorage();
if (!storage || typeof (storage as any).adapter?.hGet !== 'function') {
console.warn('Redis Hash not supported');
return false;
}
try {
const dataStr = await (storage as any).adapter.hGet(hashKey, tokenId);
if (!dataStr) {
return false;
}
const tokenData: TokenData = JSON.parse(dataStr);
// 检查是否过期
if (Date.now() > tokenData.expiresAt) {
// 过期了,删除
await (storage as any).adapter.hDel(hashKey, tokenId);
return false;
}
// 验证 Token
if (tokenData.token !== refreshToken) {
return false;
}
// 更新最后使用时间
tokenData.lastUsed = Date.now();
await (storage as any).adapter.hSet(
hashKey,
tokenId,
JSON.stringify(tokenData)
);
return true;
} catch (error) {
console.error('Failed to verify refresh token:', error);
return false;
}
}
// 撤销单个 Token
export async function revokeRefreshToken(
username: string,
tokenId: string
): Promise<void> {
const hashKey = `user_tokens:${username}`;
const storage = getStorage();
if (!storage || typeof (storage as any).adapter?.hDel !== 'function') {
console.warn('Redis Hash not supported');
return;
}
try {
await (storage as any).adapter.hDel(hashKey, tokenId);
console.log(`Revoked refresh token for ${username}:${tokenId}`);
} catch (error) {
console.error('Failed to revoke refresh token:', error);
}
}
// 获取用户的所有设备
export async function getUserDevices(username: string): Promise<Array<{
tokenId: string;
deviceInfo: string;
createdAt: number;
lastUsed: number;
expiresAt: number;
}>> {
const hashKey = `user_tokens:${username}`;
const storage = getStorage();
if (!storage || typeof (storage as any).adapter?.hGetAll !== 'function') {
console.warn('Redis Hash not supported');
return [];
}
try {
const allTokens = await (storage as any).adapter.hGetAll(hashKey);
if (!allTokens || typeof allTokens !== 'object') {
return [];
}
const devices = [];
const now = Date.now();
for (const [tokenId, dataStr] of Object.entries(allTokens)) {
try {
const tokenData: TokenData = JSON.parse(dataStr as string);
// 检查是否过期
if (now > tokenData.expiresAt) {
// 过期了,删除
await (storage as any).adapter.hDel(hashKey, tokenId);
continue;
}
devices.push({
tokenId,
deviceInfo: tokenData.deviceInfo,
createdAt: tokenData.createdAt,
lastUsed: tokenData.lastUsed,
expiresAt: tokenData.expiresAt,
});
} catch (err) {
console.error(`Failed to parse token data for ${tokenId}:`, err);
}
}
return devices;
} catch (error) {
console.error('Failed to get user devices:', error);
return [];
}
}
// 撤销所有 Token
export async function revokeAllRefreshTokens(username: string): Promise<void> {
const hashKey = `user_tokens:${username}`;
const storage = getStorage();
if (!storage || typeof (storage as any).adapter?.del !== 'function') {
console.warn('Redis Hash not supported');
return;
}
try {
await (storage as any).adapter.del(hashKey);
console.log(`Revoked all refresh tokens for ${username}`);
} catch (error) {
console.error('Failed to revoke all refresh tokens:', error);
}
}
// 清理过期的 Token(定期任务)
export async function cleanupExpiredTokens(username: string): Promise<number> {
const hashKey = `user_tokens:${username}`;
const storage = getStorage();
if (!storage || typeof (storage as any).adapter?.hGetAll !== 'function') {
return 0;
}
try {
const allTokens = await (storage as any).adapter.hGetAll(hashKey);
if (!allTokens || typeof allTokens !== 'object') {
return 0;
}
const now = Date.now();
let cleanedCount = 0;
for (const [tokenId, dataStr] of Object.entries(allTokens)) {
try {
const tokenData: TokenData = JSON.parse(dataStr as string);
if (now > tokenData.expiresAt) {
await (storage as any).adapter.hDel(hashKey, tokenId);
cleanedCount++;
}
} catch (err) {
console.error(`Failed to parse token data for ${tokenId}:`, err);
}
}
if (cleanedCount > 0) {
console.log(`Cleaned up ${cleanedCount} expired tokens for ${username}`);
}
return cleanedCount;
} catch (error) {
console.error('Failed to cleanup expired tokens:', error);
return 0;
}
}