diff --git a/src/app/api/admin/user-devices/route.ts b/src/app/api/admin/user-devices/route.ts index 793394b..e0c6373 100644 --- a/src/app/api/admin/user-devices/route.ts +++ b/src/app/api/admin/user-devices/route.ts @@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'; +import { invalidateDeviceAccessToken } from '@/lib/access-token-invalidation'; import { getAuthInfoFromCookie } from '@/lib/auth'; import { getConfig } from '@/lib/config'; import { db, getStorage } from '@/lib/db'; @@ -156,6 +157,7 @@ export async function DELETE(request: NextRequest) { ); } + invalidateDeviceAccessToken(targetUsername, tokenId); await revokeRefreshToken(targetUsername, tokenId); const storage = getStorage(); await storage.deletePushSubscriptionsByTokenId?.(targetUsername, tokenId); diff --git a/src/app/api/admin/user/route.ts b/src/app/api/admin/user/route.ts index b5c3c37..59e03e5 100644 --- a/src/app/api/admin/user/route.ts +++ b/src/app/api/admin/user/route.ts @@ -2,14 +2,29 @@ import { NextRequest, NextResponse } from 'next/server'; +import { invalidateUserAccessTokens } from '@/lib/access-token-invalidation'; import { getAuthInfoFromCookie } from '@/lib/auth'; import { getConfig } from '@/lib/config'; -import { db } from '@/lib/db'; +import { db, getStorage } from '@/lib/db'; import { sanitizeFeaturePermissions } from '@/lib/feature-permissions'; +import { revokeAllRefreshTokens } from '@/lib/refresh-token'; export const runtime = 'nodejs'; // 支持的操作类型 + +async function terminateUserSessions(username: string, reason: string): Promise { + try { + invalidateUserAccessTokens(username); + await revokeAllRefreshTokens(username); + const storage = getStorage(); + await storage.deleteAllPushSubscriptions?.(username); + console.log(`Terminated all sessions for ${username}: ${reason}`); + } catch (error) { + console.error(`Failed to terminate sessions for ${username}:`, error); + } +} + const ACTIONS = [ 'add', 'ban', @@ -194,6 +209,7 @@ export async function POST(request: NextRequest) { // 只更新V2存储 await db.updateUserInfoV2(targetUsername!, { banned: true }); + await terminateUserSessions(targetUsername!, 'user banned'); break; } case 'unban': { @@ -262,6 +278,7 @@ export async function POST(request: NextRequest) { // 只更新V2存储 await db.updateUserInfoV2(targetUsername!, { role: 'user' }); + await terminateUserSessions(targetUsername!, 'admin role revoked'); break; } case 'changePassword': { @@ -296,6 +313,7 @@ export async function POST(request: NextRequest) { // 使用新版本修改密码(SHA256加密) await db.changePasswordV2(targetUsername!, targetPassword); + await terminateUserSessions(targetUsername!, 'password changed by admin'); break; } case 'deleteUser': { @@ -321,7 +339,8 @@ export async function POST(request: NextRequest) { ); } - // 只删除V2存储中的用户 + // 先终止目标用户所有会话,再删除V2存储中的用户 + await terminateUserSessions(targetUsername!, 'user deleted'); await db.deleteUserV2(targetUsername!); break; diff --git a/src/app/api/auth/devices/route.ts b/src/app/api/auth/devices/route.ts index a4e966f..e0c9143 100644 --- a/src/app/api/auth/devices/route.ts +++ b/src/app/api/auth/devices/route.ts @@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'; +import { invalidateDeviceAccessToken, invalidateUserAccessTokens } from '@/lib/access-token-invalidation'; import { getAuthInfoFromCookie } from '@/lib/auth'; import { getStorage } from '@/lib/db'; import { @@ -51,6 +52,7 @@ export async function DELETE(request: NextRequest) { return NextResponse.json({ error: 'Token ID required' }, { status: 400 }); } + invalidateDeviceAccessToken(authInfo.username, tokenId); await revokeRefreshToken(authInfo.username, tokenId); const storage = getStorage(); await storage.deletePushSubscriptionsByTokenId?.(authInfo.username, tokenId); @@ -71,6 +73,7 @@ export async function POST(request: NextRequest) { } try { + invalidateUserAccessTokens(authInfo.username); await revokeAllRefreshTokens(authInfo.username); const storage = getStorage(); await storage.deleteAllPushSubscriptions?.(authInfo.username); diff --git a/src/app/api/auth/refresh/route.ts b/src/app/api/auth/refresh/route.ts index d0f2dfe..404144d 100644 --- a/src/app/api/auth/refresh/route.ts +++ b/src/app/api/auth/refresh/route.ts @@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { getAuthInfoFromCookie, parseAuthInfo } from '@/lib/auth'; +import { db } from '@/lib/db'; import { refreshAccessToken } from '@/lib/middleware-auth'; import { TOKEN_CONFIG } from '@/lib/refresh-token'; @@ -73,6 +74,26 @@ export async function POST(request: NextRequest) { const now = Date.now(); + if (authInfo.username === process.env.USERNAME) { + if (authInfo.role !== 'owner') { + return NextResponse.json({ error: 'Role changed' }, { status: 401 }); + } + } else { + const userInfo = await db.getUserInfoV2(authInfo.username); + + if (!userInfo) { + return NextResponse.json({ error: 'User not found' }, { status: 401 }); + } + + if (userInfo.banned) { + return NextResponse.json({ error: 'User banned' }, { status: 403 }); + } + + if (userInfo.role !== authInfo.role) { + return NextResponse.json({ error: 'Role changed' }, { status: 401 }); + } + } + // 只检查 Refresh Token 是否过期 if (now >= authInfo.refreshExpires) { return NextResponse.json( diff --git a/src/app/api/change-password/route.ts b/src/app/api/change-password/route.ts index 82baf50..631a04c 100644 --- a/src/app/api/change-password/route.ts +++ b/src/app/api/change-password/route.ts @@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'; +import { invalidateDeviceAccessToken } from '@/lib/access-token-invalidation'; import { getAuthInfoFromCookie } from '@/lib/auth'; import { getStorage } from '@/lib/db'; import { db } from '@/lib/db'; @@ -59,6 +60,7 @@ export async function POST(request: NextRequest) { // 撤销所有非当前设备的 token for (const device of devices) { if (device.tokenId !== currentTokenId) { + invalidateDeviceAccessToken(username, device.tokenId); await revokeRefreshToken(username, device.tokenId); await storage.deletePushSubscriptionsByTokenId?.(username, device.tokenId); console.log(`Revoked token ${device.tokenId} for ${username} after password change`); diff --git a/src/app/api/logout/route.ts b/src/app/api/logout/route.ts index 0d46d18..48ab7f1 100644 --- a/src/app/api/logout/route.ts +++ b/src/app/api/logout/route.ts @@ -1,5 +1,6 @@ import { NextRequest, NextResponse } from 'next/server'; +import { invalidateDeviceAccessToken } from '@/lib/access-token-invalidation'; import { getAuthInfoFromCookie } from '@/lib/auth'; import { getStorage } from '@/lib/db'; import { revokeRefreshToken } from '@/lib/refresh-token'; @@ -12,6 +13,7 @@ export async function POST(request: NextRequest) { // 撤销当前设备的 Refresh Token if (authInfo && authInfo.username && authInfo.tokenId) { try { + invalidateDeviceAccessToken(authInfo.username, authInfo.tokenId); await revokeRefreshToken(authInfo.username, authInfo.tokenId); const storage = getStorage(); await storage.deletePushSubscriptionsByTokenId?.(authInfo.username, authInfo.tokenId); diff --git a/src/lib/access-token-invalidation.ts b/src/lib/access-token-invalidation.ts new file mode 100644 index 0000000..d3fb739 --- /dev/null +++ b/src/lib/access-token-invalidation.ts @@ -0,0 +1,56 @@ +import type { AuthInfo } from './auth'; + +const userInvalidBefore = new Map(); +const deviceInvalidBefore = new Map(); + +function makeDeviceKey(username: string, tokenId: string): string { + return `${username}:${tokenId}`; +} + +export function invalidateUserAccessTokens(username: string, invalidatedAt = Date.now()): void { + const current = userInvalidBefore.get(username) || 0; + if (invalidatedAt > current) { + userInvalidBefore.set(username, invalidatedAt); + } +} + +export function invalidateDeviceAccessToken( + username: string, + tokenId: string, + invalidatedAt = Date.now() +): void { + const key = makeDeviceKey(username, tokenId); + const current = deviceInvalidBefore.get(key) || 0; + if (invalidatedAt > current) { + deviceInvalidBefore.set(key, invalidatedAt); + } +} + +export function isAccessTokenInvalidated(authInfo: AuthInfo | null): boolean { + if (!authInfo?.username || !authInfo.timestamp) { + return false; + } + + const userInvalidatedAt = userInvalidBefore.get(authInfo.username); + if (userInvalidatedAt && authInfo.timestamp <= userInvalidatedAt) { + return true; + } + + if (authInfo.tokenId) { + const deviceInvalidatedAt = deviceInvalidBefore.get( + makeDeviceKey(authInfo.username, authInfo.tokenId) + ); + if (deviceInvalidatedAt && authInfo.timestamp <= deviceInvalidatedAt) { + return true; + } + } + + return false; +} + +export function getAccessTokenInvalidationState() { + return { + userInvalidBefore, + deviceInvalidBefore, + }; +} diff --git a/src/lib/auth.ts b/src/lib/auth.ts index aff1762..19cc3c0 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -1,5 +1,7 @@ import { NextRequest } from 'next/server'; +import { isAccessTokenInvalidated } from './access-token-invalidation'; + export type AuthInfo = { password?: string; username?: string; @@ -65,7 +67,7 @@ export function getAuthInfoFromCookie(request: NextRequest): AuthInfo | null { const headerValue = getAuthTokenFromHeader(authHeader); const headerAuthInfo = parseAuthInfo(headerValue); if (headerAuthInfo) { - return headerAuthInfo; + return isAccessTokenInvalidated(headerAuthInfo) ? null : headerAuthInfo; } } @@ -75,7 +77,8 @@ export function getAuthInfoFromCookie(request: NextRequest): AuthInfo | null { return null; } - return parseAuthInfo(authCookie.value); + const authInfo = parseAuthInfo(authCookie.value); + return isAccessTokenInvalidated(authInfo) ? null : authInfo; } // 从cookie获取认证信息 (客户端使用) diff --git a/src/middleware.ts b/src/middleware.ts index 94e0144..8f3770d 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'; +import { isAccessTokenInvalidated } from '@/lib/access-token-invalidation'; import { getAuthInfoFromCookie } from '@/lib/auth'; import { TOKEN_CONFIG } from '@/lib/refresh-token'; import { isTVModeEnabled } from '@/lib/tv-mode'; @@ -89,6 +90,11 @@ export async function middleware(request: NextRequest) { return handleAuthFailure(request, pathname); } + if (isAccessTokenInvalidated(authInfo)) { + console.log(`Access token invalidated for ${authInfo.username}`); + return handleAuthFailure(request, pathname); + } + // 签名验证通过 // 注意:Token 续期由前端负责,Middleware 不再自动刷新 return NextResponse.next();