新登录验证机制
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user