用户变动撤销所有刷新token,立即失效accesstoken
This commit is contained in:
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
import { invalidateDeviceAccessToken } from '@/lib/access-token-invalidation';
|
||||||
import { getAuthInfoFromCookie } from '@/lib/auth';
|
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||||
import { getConfig } from '@/lib/config';
|
import { getConfig } from '@/lib/config';
|
||||||
import { db, getStorage } from '@/lib/db';
|
import { db, getStorage } from '@/lib/db';
|
||||||
@@ -156,6 +157,7 @@ export async function DELETE(request: NextRequest) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
invalidateDeviceAccessToken(targetUsername, tokenId);
|
||||||
await revokeRefreshToken(targetUsername, tokenId);
|
await revokeRefreshToken(targetUsername, tokenId);
|
||||||
const storage = getStorage();
|
const storage = getStorage();
|
||||||
await storage.deletePushSubscriptionsByTokenId?.(targetUsername, tokenId);
|
await storage.deletePushSubscriptionsByTokenId?.(targetUsername, tokenId);
|
||||||
|
|||||||
@@ -2,14 +2,29 @@
|
|||||||
|
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
import { invalidateUserAccessTokens } from '@/lib/access-token-invalidation';
|
||||||
import { getAuthInfoFromCookie } from '@/lib/auth';
|
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||||
import { getConfig } from '@/lib/config';
|
import { getConfig } from '@/lib/config';
|
||||||
import { db } from '@/lib/db';
|
import { db, getStorage } from '@/lib/db';
|
||||||
import { sanitizeFeaturePermissions } from '@/lib/feature-permissions';
|
import { sanitizeFeaturePermissions } from '@/lib/feature-permissions';
|
||||||
|
import { revokeAllRefreshTokens } from '@/lib/refresh-token';
|
||||||
|
|
||||||
export const runtime = 'nodejs';
|
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 = [
|
const ACTIONS = [
|
||||||
'add',
|
'add',
|
||||||
'ban',
|
'ban',
|
||||||
@@ -194,6 +209,7 @@ export async function POST(request: NextRequest) {
|
|||||||
|
|
||||||
// 只更新V2存储
|
// 只更新V2存储
|
||||||
await db.updateUserInfoV2(targetUsername!, { banned: true });
|
await db.updateUserInfoV2(targetUsername!, { banned: true });
|
||||||
|
await terminateUserSessions(targetUsername!, 'user banned');
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'unban': {
|
case 'unban': {
|
||||||
@@ -262,6 +278,7 @@ export async function POST(request: NextRequest) {
|
|||||||
|
|
||||||
// 只更新V2存储
|
// 只更新V2存储
|
||||||
await db.updateUserInfoV2(targetUsername!, { role: 'user' });
|
await db.updateUserInfoV2(targetUsername!, { role: 'user' });
|
||||||
|
await terminateUserSessions(targetUsername!, 'admin role revoked');
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'changePassword': {
|
case 'changePassword': {
|
||||||
@@ -296,6 +313,7 @@ export async function POST(request: NextRequest) {
|
|||||||
|
|
||||||
// 使用新版本修改密码(SHA256加密)
|
// 使用新版本修改密码(SHA256加密)
|
||||||
await db.changePasswordV2(targetUsername!, targetPassword);
|
await db.changePasswordV2(targetUsername!, targetPassword);
|
||||||
|
await terminateUserSessions(targetUsername!, 'password changed by admin');
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'deleteUser': {
|
case 'deleteUser': {
|
||||||
@@ -321,7 +339,8 @@ export async function POST(request: NextRequest) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 只删除V2存储中的用户
|
// 先终止目标用户所有会话,再删除V2存储中的用户
|
||||||
|
await terminateUserSessions(targetUsername!, 'user deleted');
|
||||||
await db.deleteUserV2(targetUsername!);
|
await db.deleteUserV2(targetUsername!);
|
||||||
|
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
import { invalidateDeviceAccessToken, invalidateUserAccessTokens } from '@/lib/access-token-invalidation';
|
||||||
import { getAuthInfoFromCookie } from '@/lib/auth';
|
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||||
import { getStorage } from '@/lib/db';
|
import { getStorage } from '@/lib/db';
|
||||||
import {
|
import {
|
||||||
@@ -51,6 +52,7 @@ export async function DELETE(request: NextRequest) {
|
|||||||
return NextResponse.json({ error: 'Token ID required' }, { status: 400 });
|
return NextResponse.json({ error: 'Token ID required' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
invalidateDeviceAccessToken(authInfo.username, tokenId);
|
||||||
await revokeRefreshToken(authInfo.username, tokenId);
|
await revokeRefreshToken(authInfo.username, tokenId);
|
||||||
const storage = getStorage();
|
const storage = getStorage();
|
||||||
await storage.deletePushSubscriptionsByTokenId?.(authInfo.username, tokenId);
|
await storage.deletePushSubscriptionsByTokenId?.(authInfo.username, tokenId);
|
||||||
@@ -71,6 +73,7 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
invalidateUserAccessTokens(authInfo.username);
|
||||||
await revokeAllRefreshTokens(authInfo.username);
|
await revokeAllRefreshTokens(authInfo.username);
|
||||||
const storage = getStorage();
|
const storage = getStorage();
|
||||||
await storage.deleteAllPushSubscriptions?.(authInfo.username);
|
await storage.deleteAllPushSubscriptions?.(authInfo.username);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
import { getAuthInfoFromCookie, parseAuthInfo } from '@/lib/auth';
|
import { getAuthInfoFromCookie, parseAuthInfo } from '@/lib/auth';
|
||||||
|
import { db } from '@/lib/db';
|
||||||
import { refreshAccessToken } from '@/lib/middleware-auth';
|
import { refreshAccessToken } from '@/lib/middleware-auth';
|
||||||
import { TOKEN_CONFIG } from '@/lib/refresh-token';
|
import { TOKEN_CONFIG } from '@/lib/refresh-token';
|
||||||
|
|
||||||
@@ -73,6 +74,26 @@ export async function POST(request: NextRequest) {
|
|||||||
|
|
||||||
const now = Date.now();
|
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 是否过期
|
// 只检查 Refresh Token 是否过期
|
||||||
if (now >= authInfo.refreshExpires) {
|
if (now >= authInfo.refreshExpires) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
import { invalidateDeviceAccessToken } from '@/lib/access-token-invalidation';
|
||||||
import { getAuthInfoFromCookie } from '@/lib/auth';
|
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||||
import { getStorage } from '@/lib/db';
|
import { getStorage } from '@/lib/db';
|
||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
@@ -59,6 +60,7 @@ export async function POST(request: NextRequest) {
|
|||||||
// 撤销所有非当前设备的 token
|
// 撤销所有非当前设备的 token
|
||||||
for (const device of devices) {
|
for (const device of devices) {
|
||||||
if (device.tokenId !== currentTokenId) {
|
if (device.tokenId !== currentTokenId) {
|
||||||
|
invalidateDeviceAccessToken(username, device.tokenId);
|
||||||
await revokeRefreshToken(username, device.tokenId);
|
await revokeRefreshToken(username, device.tokenId);
|
||||||
await storage.deletePushSubscriptionsByTokenId?.(username, device.tokenId);
|
await storage.deletePushSubscriptionsByTokenId?.(username, device.tokenId);
|
||||||
console.log(`Revoked token ${device.tokenId} for ${username} after password change`);
|
console.log(`Revoked token ${device.tokenId} for ${username} after password change`);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
import { invalidateDeviceAccessToken } from '@/lib/access-token-invalidation';
|
||||||
import { getAuthInfoFromCookie } from '@/lib/auth';
|
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||||
import { getStorage } from '@/lib/db';
|
import { getStorage } from '@/lib/db';
|
||||||
import { revokeRefreshToken } from '@/lib/refresh-token';
|
import { revokeRefreshToken } from '@/lib/refresh-token';
|
||||||
@@ -12,6 +13,7 @@ export async function POST(request: NextRequest) {
|
|||||||
// 撤销当前设备的 Refresh Token
|
// 撤销当前设备的 Refresh Token
|
||||||
if (authInfo && authInfo.username && authInfo.tokenId) {
|
if (authInfo && authInfo.username && authInfo.tokenId) {
|
||||||
try {
|
try {
|
||||||
|
invalidateDeviceAccessToken(authInfo.username, authInfo.tokenId);
|
||||||
await revokeRefreshToken(authInfo.username, authInfo.tokenId);
|
await revokeRefreshToken(authInfo.username, authInfo.tokenId);
|
||||||
const storage = getStorage();
|
const storage = getStorage();
|
||||||
await storage.deletePushSubscriptionsByTokenId?.(authInfo.username, authInfo.tokenId);
|
await storage.deletePushSubscriptionsByTokenId?.(authInfo.username, authInfo.tokenId);
|
||||||
|
|||||||
@@ -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
@@ -1,5 +1,7 @@
|
|||||||
import { NextRequest } from 'next/server';
|
import { NextRequest } from 'next/server';
|
||||||
|
|
||||||
|
import { isAccessTokenInvalidated } from './access-token-invalidation';
|
||||||
|
|
||||||
export type AuthInfo = {
|
export type AuthInfo = {
|
||||||
password?: string;
|
password?: string;
|
||||||
username?: string;
|
username?: string;
|
||||||
@@ -65,7 +67,7 @@ export function getAuthInfoFromCookie(request: NextRequest): AuthInfo | null {
|
|||||||
const headerValue = getAuthTokenFromHeader(authHeader);
|
const headerValue = getAuthTokenFromHeader(authHeader);
|
||||||
const headerAuthInfo = parseAuthInfo(headerValue);
|
const headerAuthInfo = parseAuthInfo(headerValue);
|
||||||
if (headerAuthInfo) {
|
if (headerAuthInfo) {
|
||||||
return headerAuthInfo;
|
return isAccessTokenInvalidated(headerAuthInfo) ? null : headerAuthInfo;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,7 +77,8 @@ export function getAuthInfoFromCookie(request: NextRequest): AuthInfo | null {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return parseAuthInfo(authCookie.value);
|
const authInfo = parseAuthInfo(authCookie.value);
|
||||||
|
return isAccessTokenInvalidated(authInfo) ? null : authInfo;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 从cookie获取认证信息 (客户端使用)
|
// 从cookie获取认证信息 (客户端使用)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
import { isAccessTokenInvalidated } from '@/lib/access-token-invalidation';
|
||||||
import { getAuthInfoFromCookie } from '@/lib/auth';
|
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||||
import { TOKEN_CONFIG } from '@/lib/refresh-token';
|
import { TOKEN_CONFIG } from '@/lib/refresh-token';
|
||||||
import { isTVModeEnabled } from '@/lib/tv-mode';
|
import { isTVModeEnabled } from '@/lib/tv-mode';
|
||||||
@@ -89,6 +90,11 @@ export async function middleware(request: NextRequest) {
|
|||||||
return handleAuthFailure(request, pathname);
|
return handleAuthFailure(request, pathname);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isAccessTokenInvalidated(authInfo)) {
|
||||||
|
console.log(`Access token invalidated for ${authInfo.username}`);
|
||||||
|
return handleAuthFailure(request, pathname);
|
||||||
|
}
|
||||||
|
|
||||||
// 签名验证通过
|
// 签名验证通过
|
||||||
// 注意:Token 续期由前端负责,Middleware 不再自动刷新
|
// 注意:Token 续期由前端负责,Middleware 不再自动刷新
|
||||||
return NextResponse.next();
|
return NextResponse.next();
|
||||||
|
|||||||
Reference in New Issue
Block a user