feat: implement user and device cache invalidation in AuthService

This commit is contained in:
shuaiplus
2026-05-12 19:12:53 +08:00
parent 2685741386
commit 17ceec45b1
6 changed files with 46 additions and 10 deletions
+2 -2
View File
@@ -5,10 +5,10 @@
accessTokenTtlSeconds: 7200, accessTokenTtlSeconds: 7200,
// Refresh token lifetime in milliseconds. // Refresh token lifetime in milliseconds.
// 刷新令牌有效期(毫秒)。 // 刷新令牌有效期(毫秒)。
refreshTokenTtlMs: 30 * 24 * 60 * 60 * 1000, refreshTokenTtlMs: 365 * 24 * 60 * 60 * 1000,
// Grace window for previous refresh token after rotation (ms). // Grace window for previous refresh token after rotation (ms).
// 刷新令牌轮换后的旧令牌宽限窗口(毫秒)。 // 刷新令牌轮换后的旧令牌宽限窗口(毫秒)。
refreshTokenOverlapGraceMs: 60 * 1000, refreshTokenOverlapGraceMs: 30 * 60 * 1000,
// Refresh token random byte length. // Refresh token random byte length.
// 刷新令牌随机字节长度。 // 刷新令牌随机字节长度。
refreshTokenRandomBytes: 32, refreshTokenRandomBytes: 32,
+5
View File
@@ -526,6 +526,7 @@ export async function handleChangePassword(request: Request, env: Env, userId: s
user.updatedAt = new Date().toISOString(); user.updatedAt = new Date().toISOString();
await storage.saveUser(user); await storage.saveUser(user);
await storage.deleteRefreshTokensByUserId(user.id); await storage.deleteRefreshTokensByUserId(user.id);
AuthService.invalidateUserCache(user.id);
await storage.createAuditLog({ await storage.createAuditLog({
id: generateUUID(), id: generateUUID(),
actorUserId: user.id, actorUserId: user.id,
@@ -587,6 +588,7 @@ export async function handleSetTotpStatus(request: Request, env: Env, userId: st
user.updatedAt = new Date().toISOString(); user.updatedAt = new Date().toISOString();
await storage.saveUser(user); await storage.saveUser(user);
await storage.deleteRefreshTokensByUserId(user.id); await storage.deleteRefreshTokensByUserId(user.id);
AuthService.invalidateUserCache(user.id);
return jsonResponse({ enabled: true, recoveryCode: user.totpRecoveryCode, object: 'twoFactor' }); return jsonResponse({ enabled: true, recoveryCode: user.totpRecoveryCode, object: 'twoFactor' });
} }
@@ -601,6 +603,7 @@ export async function handleSetTotpStatus(request: Request, env: Env, userId: st
user.updatedAt = new Date().toISOString(); user.updatedAt = new Date().toISOString();
await storage.saveUser(user); await storage.saveUser(user);
await storage.deleteRefreshTokensByUserId(user.id); await storage.deleteRefreshTokensByUserId(user.id);
AuthService.invalidateUserCache(user.id);
return jsonResponse({ enabled: false, object: 'twoFactor' }); return jsonResponse({ enabled: false, object: 'twoFactor' });
} }
@@ -708,6 +711,7 @@ export async function handleRecoverTwoFactor(request: Request, env: Env): Promis
user.updatedAt = new Date().toISOString(); user.updatedAt = new Date().toISOString();
await storage.saveUser(user); await storage.saveUser(user);
await storage.deleteRefreshTokensByUserId(user.id); await storage.deleteRefreshTokensByUserId(user.id);
AuthService.invalidateUserCache(user.id);
await rateLimit.clearLoginAttempts(recoverLimitKey); await rateLimit.clearLoginAttempts(recoverLimitKey);
return jsonResponse({ return jsonResponse({
@@ -801,6 +805,7 @@ async function apiKey(request: Request, env: Env, userId: string, rotate: boolea
} }
user.updatedAt = new Date().toISOString(); user.updatedAt = new Date().toISOString();
await storage.saveUser(user); await storage.saveUser(user);
AuthService.invalidateUserCache(user.id);
} }
return jsonResponse({ return jsonResponse({
+3
View File
@@ -1,4 +1,5 @@
import { Env, User, Invite } from '../types'; import { Env, User, Invite } from '../types';
import { AuthService } from '../services/auth';
import { StorageService } from '../services/storage'; import { StorageService } from '../services/storage';
import { jsonResponse, errorResponse } from '../utils/response'; import { jsonResponse, errorResponse } from '../utils/response';
import { generateUUID } from '../utils/uuid'; import { generateUUID } from '../utils/uuid';
@@ -222,6 +223,7 @@ export async function handleAdminSetUserStatus(
if (nextStatus === 'banned') { if (nextStatus === 'banned') {
await storage.deleteRefreshTokensByUserId(target.id); await storage.deleteRefreshTokensByUserId(target.id);
} }
AuthService.invalidateUserCache(target.id);
await writeAuditLog(storage, actorUser.id, 'admin.user.status', 'user', target.id, { await writeAuditLog(storage, actorUser.id, 'admin.user.status', 'user', target.id, {
status: nextStatus, status: nextStatus,
}); });
@@ -280,6 +282,7 @@ export async function handleAdminDeleteUser(
await storage.deleteRefreshTokensByUserId(target.id); await storage.deleteRefreshTokensByUserId(target.id);
await storage.deleteUserById(target.id); await storage.deleteUserById(target.id);
AuthService.invalidateUserCache(target.id);
await writeAuditLog(storage, actorUser.id, 'admin.user.delete', 'user', target.id, { await writeAuditLog(storage, actorUser.id, 'admin.user.delete', 'user', target.id, {
email: target.email, email: target.email,
}); });
+4
View File
@@ -1,6 +1,7 @@
import type { Device, DevicePendingAuthRequest, DeviceResponse, ProtectedDeviceResponse as ProtectedDeviceWireResponse } from '../types'; import type { Device, DevicePendingAuthRequest, DeviceResponse, ProtectedDeviceResponse as ProtectedDeviceWireResponse } from '../types';
import { Env } from '../types'; import { Env } from '../types';
import { getOnlineUserDevices, notifyUserLogout } from '../durable/notifications-hub'; import { getOnlineUserDevices, notifyUserLogout } from '../durable/notifications-hub';
import { AuthService } from '../services/auth';
import { StorageService } from '../services/storage'; import { StorageService } from '../services/storage';
import { errorResponse, jsonResponse } from '../utils/response'; import { errorResponse, jsonResponse } from '../utils/response';
import { readKnownDeviceProbe } from '../utils/device'; import { readKnownDeviceProbe } from '../utils/device';
@@ -309,6 +310,7 @@ export async function handleDeleteDevice(
await storage.deleteRefreshTokensByDevice(userId, normalized); await storage.deleteRefreshTokensByDevice(userId, normalized);
const deleted = await storage.deleteDevice(userId, normalized); const deleted = await storage.deleteDevice(userId, normalized);
if (deleted) { if (deleted) {
AuthService.invalidateDeviceCache(userId, normalized);
notifyUserLogout(env, userId, normalized); notifyUserLogout(env, userId, normalized);
} }
return jsonResponse({ success: deleted }); return jsonResponse({ success: deleted });
@@ -352,6 +354,7 @@ export async function handleDeleteAllDevices(request: Request, env: Env, userId:
user.securityStamp = generateUUID(); user.securityStamp = generateUUID();
user.updatedAt = new Date().toISOString(); user.updatedAt = new Date().toISOString();
await storage.saveUser(user); await storage.saveUser(user);
AuthService.invalidateUserCache(userId);
notifyUserLogout(env, userId, null); notifyUserLogout(env, userId, null);
return jsonResponse({ success: true, removedTrusted, removedSessions: removedSessions ?? 0, removedDevices }); return jsonResponse({ success: true, removedTrusted, removedSessions: removedSessions ?? 0, removedDevices });
} }
@@ -483,6 +486,7 @@ export async function handleDeactivateDevice(
await storage.deleteRefreshTokensByDevice(userId, normalized); await storage.deleteRefreshTokensByDevice(userId, normalized);
const deleted = await storage.deleteDevice(userId, normalized); const deleted = await storage.deleteDevice(userId, normalized);
if (deleted) { if (deleted) {
AuthService.invalidateDeviceCache(userId, normalized);
notifyUserLogout(env, userId, normalized); notifyUserLogout(env, userId, normalized);
} }
return jsonResponse({ success: deleted }); return jsonResponse({ success: deleted });
+13 -8
View File
@@ -32,6 +32,17 @@ function resolveTotpSecret(userSecret: string | null): string | null {
return null; return null;
} }
async function resolveDeviceSession(
storage: StorageService,
userId: string,
deviceInfo: ReturnType<typeof readAuthRequestDeviceInfo>
): Promise<{ identifier: string; sessionStamp: string } | null> {
if (!deviceInfo.deviceIdentifier) return null;
const existingDevice = await storage.getDevice(userId, deviceInfo.deviceIdentifier);
const sessionStamp = String(existingDevice?.sessionStamp || '').trim() || generateUUID();
return { identifier: deviceInfo.deviceIdentifier, sessionStamp };
}
function shouldUseWebSession(request: Request): boolean { function shouldUseWebSession(request: Request): boolean {
return String(request.headers.get('X-NodeWarden-Web-Session') || '').trim() === '1'; return String(request.headers.get('X-NodeWarden-Web-Session') || '').trim() === '1';
} }
@@ -320,10 +331,7 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
} }
// Persist device only after successful password + (optional) 2FA verification. // Persist device only after successful password + (optional) 2FA verification.
const deviceSession = const deviceSession = await resolveDeviceSession(storage, user.id, deviceInfo);
deviceInfo.deviceIdentifier
? { identifier: deviceInfo.deviceIdentifier, sessionStamp: generateUUID() }
: null;
if (deviceSession) { if (deviceSession) {
await storage.upsertDevice( await storage.upsertDevice(
user.id, user.id,
@@ -413,10 +421,7 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
} }
// Persist device only after successful client credential verification. // Persist device only after successful client credential verification.
const deviceSession = const deviceSession = await resolveDeviceSession(storage, user.id, deviceInfo);
deviceInfo.deviceIdentifier
? { identifier: deviceInfo.deviceIdentifier, sessionStamp: generateUUID() }
: null;
if (deviceSession) { if (deviceSession) {
await storage.upsertDevice( await storage.upsertDevice(
user.id, user.id,
+19
View File
@@ -32,6 +32,25 @@ export class AuthService {
this.storage = new StorageService(env.DB); this.storage = new StorageService(env.DB);
} }
static invalidateUserCache(userId: string): void {
const normalizedUserId = String(userId || '').trim();
if (!normalizedUserId) return;
AuthService.userCache.delete(normalizedUserId);
const prefix = `${normalizedUserId}:`;
for (const key of AuthService.deviceCache.keys()) {
if (key.startsWith(prefix)) {
AuthService.deviceCache.delete(key);
}
}
}
static invalidateDeviceCache(userId: string, deviceId: string): void {
const normalizedUserId = String(userId || '').trim();
const normalizedDeviceId = String(deviceId || '').trim();
if (!normalizedUserId || !normalizedDeviceId) return;
AuthService.deviceCache.delete(`${normalizedUserId}:${normalizedDeviceId}`);
}
private readCachedUser(userId: string): User | null | undefined { private readCachedUser(userId: string): User | null | undefined {
const cached = AuthService.userCache.get(userId); const cached = AuthService.userCache.get(userId);
if (!cached) return undefined; if (!cached) return undefined;