新登录验证机制
This commit is contained in:
@@ -0,0 +1,89 @@
|
||||
/* eslint-disable no-console */
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||
import {
|
||||
getUserDevices,
|
||||
revokeAllRefreshTokens,
|
||||
revokeRefreshToken,
|
||||
} from '@/lib/refresh-token';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
// 获取所有设备
|
||||
export async function GET(request: NextRequest) {
|
||||
const authInfo = getAuthInfoFromCookie(request);
|
||||
|
||||
if (!authInfo || !authInfo.username) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const devices = await getUserDevices(authInfo.username);
|
||||
|
||||
// 标记当前设备
|
||||
const devicesWithCurrent = devices.map((device) => ({
|
||||
...device,
|
||||
isCurrent: device.tokenId === authInfo.tokenId,
|
||||
}));
|
||||
|
||||
return NextResponse.json({ devices: devicesWithCurrent });
|
||||
} catch (error) {
|
||||
console.error('Failed to get devices:', error);
|
||||
return NextResponse.json({ error: 'Server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// 撤销指定设备
|
||||
export async function DELETE(request: NextRequest) {
|
||||
const authInfo = getAuthInfoFromCookie(request);
|
||||
|
||||
if (!authInfo || !authInfo.username) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const { tokenId } = await request.json();
|
||||
|
||||
if (!tokenId) {
|
||||
return NextResponse.json({ error: 'Token ID required' }, { status: 400 });
|
||||
}
|
||||
|
||||
await revokeRefreshToken(authInfo.username, tokenId);
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (error) {
|
||||
console.error('Failed to revoke device:', error);
|
||||
return NextResponse.json({ error: 'Server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// 登出所有设备
|
||||
export async function POST(request: NextRequest) {
|
||||
const authInfo = getAuthInfoFromCookie(request);
|
||||
|
||||
if (!authInfo || !authInfo.username) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
await revokeAllRefreshTokens(authInfo.username);
|
||||
|
||||
const response = NextResponse.json({ ok: true });
|
||||
|
||||
// 清除当前设备的 Cookie
|
||||
response.cookies.set('auth', '', {
|
||||
path: '/',
|
||||
expires: new Date(0),
|
||||
sameSite: 'lax',
|
||||
httpOnly: false,
|
||||
secure: false,
|
||||
});
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error('Failed to revoke all devices:', error);
|
||||
return NextResponse.json({ error: 'Server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,12 @@ import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { getConfig } from '@/lib/config';
|
||||
import { db } from '@/lib/db';
|
||||
import {
|
||||
generateRefreshToken,
|
||||
generateTokenId,
|
||||
storeRefreshToken,
|
||||
TOKEN_CONFIG,
|
||||
} from '@/lib/refresh-token';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
@@ -30,18 +36,66 @@ async function generateSignature(
|
||||
.join('');
|
||||
}
|
||||
|
||||
// 获取设备信息
|
||||
function getDeviceInfo(userAgent: string): string {
|
||||
const ua = userAgent.toLowerCase();
|
||||
|
||||
if (ua.includes('mobile') || ua.includes('android') || ua.includes('iphone')) {
|
||||
if (ua.includes('android')) return 'Android Mobile';
|
||||
if (ua.includes('iphone')) return 'iPhone';
|
||||
return 'Mobile Device';
|
||||
}
|
||||
|
||||
if (ua.includes('tablet') || ua.includes('ipad')) {
|
||||
return 'Tablet';
|
||||
}
|
||||
|
||||
if (ua.includes('windows')) return 'Windows PC';
|
||||
if (ua.includes('mac')) return 'Mac';
|
||||
if (ua.includes('linux')) return 'Linux';
|
||||
|
||||
return 'Unknown Device';
|
||||
}
|
||||
|
||||
// 生成认证Cookie
|
||||
async function generateAuthCookie(
|
||||
username: string,
|
||||
role: 'owner' | 'admin' | 'user'
|
||||
role: 'owner' | 'admin' | 'user',
|
||||
deviceInfo: string
|
||||
): Promise<string> {
|
||||
const authData: any = { role };
|
||||
|
||||
if (username && process.env.PASSWORD) {
|
||||
authData.username = username;
|
||||
const signature = await generateSignature(username, process.env.PASSWORD);
|
||||
authData.signature = signature;
|
||||
authData.timestamp = Date.now();
|
||||
|
||||
// 生成签名(包含 username, role, timestamp)
|
||||
const dataToSign = JSON.stringify({
|
||||
username: authData.username,
|
||||
role: authData.role,
|
||||
timestamp: authData.timestamp
|
||||
});
|
||||
const signature = await generateSignature(dataToSign, process.env.PASSWORD);
|
||||
authData.signature = signature;
|
||||
|
||||
// 生成双 Token
|
||||
const tokenId = generateTokenId();
|
||||
const refreshToken = generateRefreshToken();
|
||||
const now = Date.now();
|
||||
const refreshExpires = now + TOKEN_CONFIG.REFRESH_TOKEN_AGE;
|
||||
|
||||
authData.tokenId = tokenId;
|
||||
authData.refreshToken = refreshToken;
|
||||
authData.refreshExpires = refreshExpires;
|
||||
|
||||
// 存储 Refresh Token
|
||||
await storeRefreshToken(username, tokenId, {
|
||||
token: refreshToken,
|
||||
deviceInfo,
|
||||
createdAt: now,
|
||||
expiresAt: refreshExpires,
|
||||
lastUsed: now,
|
||||
});
|
||||
}
|
||||
|
||||
return encodeURIComponent(JSON.stringify(authData));
|
||||
@@ -182,9 +236,10 @@ export async function GET(request: NextRequest) {
|
||||
if (username) {
|
||||
// 用户已存在,直接登录
|
||||
const response = NextResponse.redirect(new URL('/', origin));
|
||||
const cookieValue = await generateAuthCookie(username, userRole);
|
||||
const expires = new Date();
|
||||
expires.setDate(expires.getDate() + 7);
|
||||
const userAgent = request.headers.get('user-agent') || 'Unknown';
|
||||
const deviceInfo = getDeviceInfo(userAgent);
|
||||
const cookieValue = await generateAuthCookie(username, userRole, deviceInfo);
|
||||
const expires = new Date(Date.now() + TOKEN_CONFIG.REFRESH_TOKEN_AGE);
|
||||
|
||||
response.cookies.set('auth', cookieValue, {
|
||||
path: '/',
|
||||
|
||||
@@ -3,6 +3,12 @@ import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { getConfig } from '@/lib/config';
|
||||
import { db } from '@/lib/db';
|
||||
import {
|
||||
generateRefreshToken,
|
||||
generateTokenId,
|
||||
storeRefreshToken,
|
||||
TOKEN_CONFIG,
|
||||
} from '@/lib/refresh-token';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
@@ -30,18 +36,66 @@ async function generateSignature(
|
||||
.join('');
|
||||
}
|
||||
|
||||
// 获取设备信息
|
||||
function getDeviceInfo(userAgent: string): string {
|
||||
const ua = userAgent.toLowerCase();
|
||||
|
||||
if (ua.includes('mobile') || ua.includes('android') || ua.includes('iphone')) {
|
||||
if (ua.includes('android')) return 'Android Mobile';
|
||||
if (ua.includes('iphone')) return 'iPhone';
|
||||
return 'Mobile Device';
|
||||
}
|
||||
|
||||
if (ua.includes('tablet') || ua.includes('ipad')) {
|
||||
return 'Tablet';
|
||||
}
|
||||
|
||||
if (ua.includes('windows')) return 'Windows PC';
|
||||
if (ua.includes('mac')) return 'Mac';
|
||||
if (ua.includes('linux')) return 'Linux';
|
||||
|
||||
return 'Unknown Device';
|
||||
}
|
||||
|
||||
// 生成认证Cookie
|
||||
async function generateAuthCookie(
|
||||
username: string,
|
||||
role: 'owner' | 'admin' | 'user'
|
||||
role: 'owner' | 'admin' | 'user',
|
||||
deviceInfo: string
|
||||
): Promise<string> {
|
||||
const authData: any = { role };
|
||||
|
||||
if (username && process.env.PASSWORD) {
|
||||
authData.username = username;
|
||||
const signature = await generateSignature(username, process.env.PASSWORD);
|
||||
authData.signature = signature;
|
||||
authData.timestamp = Date.now();
|
||||
|
||||
// 生成签名(包含 username, role, timestamp)
|
||||
const dataToSign = JSON.stringify({
|
||||
username: authData.username,
|
||||
role: authData.role,
|
||||
timestamp: authData.timestamp
|
||||
});
|
||||
const signature = await generateSignature(dataToSign, process.env.PASSWORD);
|
||||
authData.signature = signature;
|
||||
|
||||
// 生成双 Token
|
||||
const tokenId = generateTokenId();
|
||||
const refreshToken = generateRefreshToken();
|
||||
const now = Date.now();
|
||||
const refreshExpires = now + TOKEN_CONFIG.REFRESH_TOKEN_AGE;
|
||||
|
||||
authData.tokenId = tokenId;
|
||||
authData.refreshToken = refreshToken;
|
||||
authData.refreshExpires = refreshExpires;
|
||||
|
||||
// 存储 Refresh Token
|
||||
await storeRefreshToken(username, tokenId, {
|
||||
token: refreshToken,
|
||||
deviceInfo,
|
||||
createdAt: now,
|
||||
expiresAt: refreshExpires,
|
||||
lastUsed: now,
|
||||
});
|
||||
}
|
||||
|
||||
return encodeURIComponent(JSON.stringify(authData));
|
||||
@@ -171,9 +225,10 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
// 设置认证cookie
|
||||
const response = NextResponse.json({ ok: true, message: '注册成功' });
|
||||
const cookieValue = await generateAuthCookie(username, 'user');
|
||||
const expires = new Date();
|
||||
expires.setDate(expires.getDate() + 7);
|
||||
const userAgent = request.headers.get('user-agent') || 'Unknown';
|
||||
const deviceInfo = getDeviceInfo(userAgent);
|
||||
const cookieValue = await generateAuthCookie(username, 'user', deviceInfo);
|
||||
const expires = new Date(Date.now() + TOKEN_CONFIG.REFRESH_TOKEN_AGE);
|
||||
|
||||
response.cookies.set('auth', cookieValue, {
|
||||
path: '/',
|
||||
|
||||
+89
-23
@@ -3,6 +3,12 @@ import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { getConfig } from '@/lib/config';
|
||||
import { db } from '@/lib/db';
|
||||
import {
|
||||
generateRefreshToken,
|
||||
generateTokenId,
|
||||
storeRefreshToken,
|
||||
TOKEN_CONFIG,
|
||||
} from '@/lib/refresh-token';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
@@ -42,13 +48,15 @@ async function generateSignature(
|
||||
.join('');
|
||||
}
|
||||
|
||||
// 生成认证Cookie(带签名)
|
||||
// 生成认证Cookie(带签名和 Refresh Token)
|
||||
async function generateAuthCookie(
|
||||
username?: string,
|
||||
password?: string,
|
||||
role?: 'owner' | 'admin' | 'user',
|
||||
includePassword = false
|
||||
includePassword = false,
|
||||
deviceInfo?: string
|
||||
): Promise<string> {
|
||||
const now = Date.now();
|
||||
const authData: any = { role: role || 'user' };
|
||||
|
||||
// 只在需要时包含 password
|
||||
@@ -58,10 +66,40 @@ async function generateAuthCookie(
|
||||
|
||||
if (username && process.env.PASSWORD) {
|
||||
authData.username = username;
|
||||
// 使用密码作为密钥对用户名进行签名
|
||||
const signature = await generateSignature(username, process.env.PASSWORD);
|
||||
authData.timestamp = now; // Access Token 时间戳
|
||||
|
||||
// 生成 Refresh Token(仅数据库模式)
|
||||
if (!includePassword && STORAGE_TYPE !== 'localstorage') {
|
||||
const tokenId = generateTokenId();
|
||||
const refreshToken = generateRefreshToken();
|
||||
const refreshExpires = now + TOKEN_CONFIG.REFRESH_TOKEN_AGE;
|
||||
|
||||
authData.tokenId = tokenId;
|
||||
authData.refreshToken = refreshToken;
|
||||
authData.refreshExpires = refreshExpires;
|
||||
|
||||
// 存储到 Redis Hash
|
||||
try {
|
||||
await storeRefreshToken(username, tokenId, {
|
||||
token: refreshToken,
|
||||
deviceInfo: deviceInfo || 'Unknown Device',
|
||||
createdAt: now,
|
||||
expiresAt: refreshExpires,
|
||||
lastUsed: now,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to store refresh token:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 签名所有关键字段(username, role, timestamp)防止篡改
|
||||
const dataToSign = JSON.stringify({
|
||||
username: authData.username,
|
||||
role: authData.role,
|
||||
timestamp: authData.timestamp
|
||||
});
|
||||
const signature = await generateSignature(dataToSign, process.env.PASSWORD);
|
||||
authData.signature = signature;
|
||||
authData.timestamp = Date.now(); // 添加时间戳防重放攻击
|
||||
}
|
||||
|
||||
return encodeURIComponent(JSON.stringify(authData));
|
||||
@@ -89,6 +127,28 @@ async function verifyTurnstileToken(token: string, secretKey: string): Promise<b
|
||||
}
|
||||
}
|
||||
|
||||
// 获取设备信息
|
||||
function getDeviceInfo(request: NextRequest): string {
|
||||
const userAgent = request.headers.get('user-agent') || 'Unknown';
|
||||
|
||||
// 简单解析 User-Agent
|
||||
let browser = 'Unknown Browser';
|
||||
let os = 'Unknown OS';
|
||||
|
||||
if (userAgent.includes('Chrome')) browser = 'Chrome';
|
||||
else if (userAgent.includes('Firefox')) browser = 'Firefox';
|
||||
else if (userAgent.includes('Safari')) browser = 'Safari';
|
||||
else if (userAgent.includes('Edge')) browser = 'Edge';
|
||||
|
||||
if (userAgent.includes('Windows')) os = 'Windows';
|
||||
else if (userAgent.includes('Mac')) os = 'macOS';
|
||||
else if (userAgent.includes('Linux')) os = 'Linux';
|
||||
else if (userAgent.includes('Android')) os = 'Android';
|
||||
else if (userAgent.includes('iOS')) os = 'iOS';
|
||||
|
||||
return `${browser} on ${os}`;
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
// 获取站点配置
|
||||
@@ -107,9 +167,9 @@ export async function POST(req: NextRequest) {
|
||||
response.cookies.set('auth', '', {
|
||||
path: '/',
|
||||
expires: new Date(0),
|
||||
sameSite: 'lax', // 改为 lax 以支持 PWA
|
||||
httpOnly: false, // PWA 需要客户端可访问
|
||||
secure: false, // 根据协议自动设置
|
||||
sameSite: 'lax',
|
||||
httpOnly: false,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
});
|
||||
|
||||
return response;
|
||||
@@ -130,21 +190,23 @@ export async function POST(req: NextRequest) {
|
||||
// 验证成功,设置认证cookie
|
||||
const response = NextResponse.json({ ok: true });
|
||||
const username = process.env.USERNAME || 'default';
|
||||
const deviceInfo = getDeviceInfo(req);
|
||||
const cookieValue = await generateAuthCookie(
|
||||
username,
|
||||
password,
|
||||
'owner',
|
||||
true
|
||||
true,
|
||||
deviceInfo
|
||||
); // localstorage 模式包含 password
|
||||
const expires = new Date();
|
||||
expires.setDate(expires.getDate() + 7); // 7天过期
|
||||
expires.setDate(expires.getDate() + 60); // 60天过期(Refresh Token 有效期)
|
||||
|
||||
response.cookies.set('auth', cookieValue, {
|
||||
path: '/',
|
||||
expires,
|
||||
sameSite: 'lax', // 改为 lax 以支持 PWA
|
||||
httpOnly: false, // PWA 需要客户端可访问
|
||||
secure: false, // 根据协议自动设置
|
||||
sameSite: 'lax',
|
||||
httpOnly: false, // 允许客户端访问
|
||||
secure: false,
|
||||
});
|
||||
|
||||
return response;
|
||||
@@ -194,21 +256,23 @@ export async function POST(req: NextRequest) {
|
||||
) {
|
||||
// 验证成功,设置认证cookie
|
||||
const response = NextResponse.json({ ok: true });
|
||||
const deviceInfo = getDeviceInfo(req);
|
||||
const cookieValue = await generateAuthCookie(
|
||||
username,
|
||||
password,
|
||||
'owner',
|
||||
false
|
||||
false,
|
||||
deviceInfo
|
||||
); // 数据库模式不包含 password
|
||||
const expires = new Date();
|
||||
expires.setDate(expires.getDate() + 7); // 7天过期
|
||||
expires.setDate(expires.getDate() + 60); // 60天过期(Refresh Token 有效期)
|
||||
|
||||
response.cookies.set('auth', cookieValue, {
|
||||
path: '/',
|
||||
expires,
|
||||
sameSite: 'lax', // 改为 lax 以支持 PWA
|
||||
httpOnly: false, // PWA 需要客户端可访问
|
||||
secure: false, // 根据协议自动设置
|
||||
sameSite: 'lax',
|
||||
httpOnly: false, // 允许客户端访问
|
||||
secure: false,
|
||||
});
|
||||
|
||||
return response;
|
||||
@@ -248,21 +312,23 @@ export async function POST(req: NextRequest) {
|
||||
|
||||
// 验证成功,设置认证cookie
|
||||
const response = NextResponse.json({ ok: true });
|
||||
const deviceInfo = getDeviceInfo(req);
|
||||
const cookieValue = await generateAuthCookie(
|
||||
username,
|
||||
password,
|
||||
userRole,
|
||||
false
|
||||
false,
|
||||
deviceInfo
|
||||
); // 数据库模式不包含 password
|
||||
const expires = new Date();
|
||||
expires.setDate(expires.getDate() + 7); // 7天过期
|
||||
expires.setDate(expires.getDate() + 60); // 60天过期(Refresh Token 有效期)
|
||||
|
||||
response.cookies.set('auth', cookieValue, {
|
||||
path: '/',
|
||||
expires,
|
||||
sameSite: 'lax', // 改为 lax 以支持 PWA
|
||||
httpOnly: false, // PWA 需要客户端可访问
|
||||
secure: false, // 根据协议自动设置
|
||||
sameSite: 'lax',
|
||||
httpOnly: false, // 允许客户端访问
|
||||
secure: process.env.NODE_ENV === 'production', // 生产环境强制 HTTPS
|
||||
});
|
||||
|
||||
console.log(`Cookie已设置`);
|
||||
|
||||
@@ -1,17 +1,31 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||
import { revokeRefreshToken } from '@/lib/refresh-token';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
export async function POST() {
|
||||
export async function POST(request: NextRequest) {
|
||||
const authInfo = getAuthInfoFromCookie(request);
|
||||
|
||||
// 撤销当前设备的 Refresh Token
|
||||
if (authInfo && authInfo.username && authInfo.tokenId) {
|
||||
try {
|
||||
await revokeRefreshToken(authInfo.username, authInfo.tokenId);
|
||||
} catch (error) {
|
||||
console.error('Failed to revoke refresh token:', error);
|
||||
}
|
||||
}
|
||||
|
||||
const response = NextResponse.json({ ok: true });
|
||||
|
||||
// 清除认证cookie
|
||||
response.cookies.set('auth', '', {
|
||||
path: '/',
|
||||
expires: new Date(0),
|
||||
sameSite: 'lax', // 改为 lax 以支持 PWA
|
||||
httpOnly: false, // PWA 需要客户端可访问
|
||||
secure: false, // 根据协议自动设置
|
||||
sameSite: 'lax',
|
||||
httpOnly: false,
|
||||
secure: false,
|
||||
});
|
||||
|
||||
return response;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
+92
-16
@@ -3,6 +3,8 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||
import { refreshAccessToken, shouldRenewToken } from '@/lib/middleware-auth';
|
||||
import { TOKEN_CONFIG } from '@/lib/refresh-token';
|
||||
|
||||
export async function middleware(request: NextRequest) {
|
||||
const { pathname } = request.nextUrl;
|
||||
@@ -35,39 +37,113 @@ export async function middleware(request: NextRequest) {
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
// 其他模式:只验证签名
|
||||
// 其他模式:验证签名和时间戳,支持自动续期
|
||||
// 检查是否有用户名(非localStorage模式下密码不存储在cookie中)
|
||||
if (!authInfo.username || !authInfo.signature) {
|
||||
if (!authInfo.username || !authInfo.signature || !authInfo.timestamp) {
|
||||
return handleAuthFailure(request, pathname);
|
||||
}
|
||||
|
||||
// 验证签名(如果存在)
|
||||
if (authInfo.signature) {
|
||||
const isValidSignature = await verifySignature(
|
||||
authInfo.username,
|
||||
authInfo.signature,
|
||||
process.env.PASSWORD || ''
|
||||
);
|
||||
// 验证 Access Token 时间戳
|
||||
const ACCESS_TOKEN_AGE = TOKEN_CONFIG.ACCESS_TOKEN_AGE;
|
||||
const now = Date.now();
|
||||
const age = now - authInfo.timestamp;
|
||||
|
||||
// 签名验证通过即可
|
||||
if (isValidSignature) {
|
||||
return NextResponse.next();
|
||||
// Access Token 已过期,尝试使用 Refresh Token 刷新
|
||||
if (age > ACCESS_TOKEN_AGE) {
|
||||
if (authInfo.refreshToken && authInfo.tokenId && authInfo.refreshExpires) {
|
||||
// 检查 Refresh Token 是否过期
|
||||
if (now < authInfo.refreshExpires) {
|
||||
// 尝试刷新 Access Token
|
||||
const newAuthData = await refreshAccessToken(
|
||||
authInfo.username,
|
||||
authInfo.role,
|
||||
authInfo.tokenId,
|
||||
authInfo.refreshToken,
|
||||
authInfo.refreshExpires
|
||||
);
|
||||
|
||||
if (newAuthData) {
|
||||
// 刷新成功,设置新 Cookie
|
||||
const response = NextResponse.next();
|
||||
const expires = new Date(authInfo.refreshExpires);
|
||||
response.cookies.set('auth', newAuthData, {
|
||||
path: '/',
|
||||
expires,
|
||||
sameSite: 'lax',
|
||||
httpOnly: false,
|
||||
secure: false,
|
||||
});
|
||||
return response;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Refresh Token 也过期或刷新失败,需要重新登录
|
||||
return handleAuthFailure(request, pathname);
|
||||
}
|
||||
|
||||
// Access Token 未过期,验证签名
|
||||
const isValidSignature = await verifySignature(
|
||||
authInfo.username,
|
||||
authInfo.role,
|
||||
authInfo.timestamp,
|
||||
authInfo.signature,
|
||||
process.env.PASSWORD || ''
|
||||
);
|
||||
|
||||
if (!isValidSignature) {
|
||||
return handleAuthFailure(request, pathname);
|
||||
}
|
||||
|
||||
// 签名验证通过,检查是否需要续期
|
||||
if (shouldRenewToken(authInfo.timestamp)) {
|
||||
// 快过期了,自动续期
|
||||
if (authInfo.refreshToken && authInfo.tokenId && authInfo.refreshExpires) {
|
||||
const newAuthData = await refreshAccessToken(
|
||||
authInfo.username,
|
||||
authInfo.role,
|
||||
authInfo.tokenId,
|
||||
authInfo.refreshToken,
|
||||
authInfo.refreshExpires
|
||||
);
|
||||
|
||||
if (newAuthData) {
|
||||
const response = NextResponse.next();
|
||||
const expires = new Date(authInfo.refreshExpires);
|
||||
response.cookies.set('auth', newAuthData, {
|
||||
path: '/',
|
||||
expires,
|
||||
sameSite: 'lax',
|
||||
httpOnly: false,
|
||||
secure: false,
|
||||
});
|
||||
return response;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 签名验证失败或不存在签名
|
||||
return handleAuthFailure(request, pathname);
|
||||
// 正常通过
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
// 验证签名
|
||||
async function verifySignature(
|
||||
data: string,
|
||||
username: string,
|
||||
role: string,
|
||||
timestamp: number,
|
||||
signature: string,
|
||||
secret: string
|
||||
): Promise<boolean> {
|
||||
const encoder = new TextEncoder();
|
||||
const keyData = encoder.encode(secret);
|
||||
const messageData = encoder.encode(data);
|
||||
|
||||
// 构造与生成签名时相同的数据结构
|
||||
const dataToSign = JSON.stringify({
|
||||
username,
|
||||
role,
|
||||
timestamp
|
||||
});
|
||||
const messageData = encoder.encode(dataToSign);
|
||||
|
||||
try {
|
||||
// 导入密钥
|
||||
|
||||
Reference in New Issue
Block a user