用户变动撤销所有刷新token,立即失效accesstoken

This commit is contained in:
mtvpls
2026-06-17 11:35:46 +08:00
parent 84afb97f9f
commit d7727455dc
9 changed files with 118 additions and 4 deletions
+2
View File
@@ -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);
+21 -2
View File
@@ -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<void> {
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;
+3
View File
@@ -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);
+21
View File
@@ -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(
+2
View File
@@ -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`);
+2
View File
@@ -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);
+56
View File
@@ -0,0 +1,56 @@
import type { AuthInfo } from './auth';
const userInvalidBefore = new Map<string, number>();
const deviceInvalidBefore = new Map<string, number>();
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,
};
}
+5 -2
View File
@@ -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获取认证信息 (客户端使用)
+6
View File
@@ -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();