From 49484d0121c6a777d6ce33725f1930554fe10115 Mon Sep 17 00:00:00 2001 From: mtvpls Date: Sat, 24 Jan 2026 15:31:13 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E7=99=BB=E5=BD=95=E9=AA=8C=E8=AF=81?= =?UTF-8?q?=E6=9C=BA=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/api/auth/devices/route.ts | 89 +++++++ src/app/api/auth/oidc/callback/route.ts | 67 ++++- .../api/auth/oidc/complete-register/route.ts | 67 ++++- src/app/api/login/route.ts | 112 ++++++-- src/app/api/logout/route.ts | 24 +- src/lib/middleware-auth.ts | 80 ++++++ src/lib/refresh-token.ts | 247 ++++++++++++++++++ src/middleware.ts | 108 ++++++-- 8 files changed, 738 insertions(+), 56 deletions(-) create mode 100644 src/app/api/auth/devices/route.ts create mode 100644 src/lib/middleware-auth.ts create mode 100644 src/lib/refresh-token.ts diff --git a/src/app/api/auth/devices/route.ts b/src/app/api/auth/devices/route.ts new file mode 100644 index 0000000..863b9c8 --- /dev/null +++ b/src/app/api/auth/devices/route.ts @@ -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 }); + } +} diff --git a/src/app/api/auth/oidc/callback/route.ts b/src/app/api/auth/oidc/callback/route.ts index 4907ae4..ccb96a5 100644 --- a/src/app/api/auth/oidc/callback/route.ts +++ b/src/app/api/auth/oidc/callback/route.ts @@ -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 { 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: '/', diff --git a/src/app/api/auth/oidc/complete-register/route.ts b/src/app/api/auth/oidc/complete-register/route.ts index 692d8c7..a0db3c1 100644 --- a/src/app/api/auth/oidc/complete-register/route.ts +++ b/src/app/api/auth/oidc/complete-register/route.ts @@ -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 { 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: '/', diff --git a/src/app/api/login/route.ts b/src/app/api/login/route.ts index a0b20ae..604b8f1 100644 --- a/src/app/api/login/route.ts +++ b/src/app/api/login/route.ts @@ -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 { + 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 { + 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 { + // 验证 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; +} diff --git a/src/lib/refresh-token.ts b/src/lib/refresh-token.ts new file mode 100644 index 0000000..ee7b580 --- /dev/null +++ b/src/lib/refresh-token.ts @@ -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 { + 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 { + 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 { + 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> { + 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 { + 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 { + 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; + } +} diff --git a/src/middleware.ts b/src/middleware.ts index 43a9a15..97141fd 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -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 { 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 { // 导入密钥