feat: added logging system

This commit is contained in:
shuaiplus
2026-05-14 02:42:15 +08:00
parent 17ceec45b1
commit 3e4c104e1d
34 changed files with 3179 additions and 66 deletions
+87 -12
View File
@@ -2,6 +2,7 @@ import { Env, User, ProfileResponse, DEFAULT_DEV_SECRET } from '../types';
import { StorageService } from '../services/storage';
import { AuthService } from '../services/auth';
import { RateLimitService, getClientIdentifier } from '../services/ratelimit';
import { auditRequestMetadata, writeAuditEvent, safeWriteAuditEvent } from '../services/audit-events';
import { jsonResponse, errorResponse } from '../utils/response';
import { generateUUID } from '../utils/uuid';
import { LIMITS } from '../config/limits';
@@ -227,14 +228,14 @@ export async function handleRegister(request: Request, env: Env): Promise<Respon
return errorResponse('Registration is temporarily unavailable, retry once', 409);
}
await storage.setRegistered();
await storage.createAuditLog({
id: generateUUID(),
await writeAuditEvent(storage, {
actorUserId: user.id,
action: 'user.register.first_admin',
targetType: 'user',
targetId: user.id,
metadata: JSON.stringify({ email: user.email }),
createdAt: now,
category: 'security',
level: 'security',
metadata: { email: user.email, ...auditRequestMetadata(request) },
});
return jsonResponse({ success: true, role: user.role }, 200);
}
@@ -259,14 +260,14 @@ export async function handleRegister(request: Request, env: Env): Promise<Respon
return errorResponse('Invite code is invalid or expired', 403);
}
await storage.createAuditLog({
id: generateUUID(),
await writeAuditEvent(storage, {
actorUserId: user.id,
action: 'user.register.invite',
targetType: 'user',
targetId: user.id,
metadata: JSON.stringify({ email: user.email, inviteCode }),
createdAt: now,
category: 'security',
level: 'info',
metadata: { email: user.email, inviteCode, ...auditRequestMetadata(request) },
});
return jsonResponse({ success: true, role: user.role }, 200);
@@ -378,6 +379,18 @@ export async function handleUpdateProfile(request: Request, env: Env, userId: st
user.masterPasswordHint = masterPasswordHint;
user.updatedAt = new Date().toISOString();
await storage.saveUser(user);
await writeAuditEvent(storage, {
actorUserId: user.id,
action: 'account.profile.update',
category: 'security',
level: 'info',
targetType: 'user',
targetId: user.id,
metadata: {
updatedMasterPasswordHint: true,
...auditRequestMetadata(request),
},
});
return jsonResponse(toProfile(user, env));
}
@@ -412,6 +425,18 @@ export async function handleSetVerifyDevices(request: Request, env: Env, userId:
user.verifyDevices = body.verifyDevices;
user.updatedAt = new Date().toISOString();
await storage.saveUser(user);
await writeAuditEvent(storage, {
actorUserId: user.id,
action: 'account.verify_devices.update',
category: 'security',
level: 'security',
targetType: 'user',
targetId: user.id,
metadata: {
verifyDevices: user.verifyDevices,
...auditRequestMetadata(request),
},
});
return new Response(null, { status: 200 });
}
@@ -461,6 +486,20 @@ export async function handleSetKeys(request: Request, env: Env, userId: string):
user.updatedAt = new Date().toISOString();
await storage.saveUser(user);
await writeAuditEvent(storage, {
actorUserId: user.id,
action: 'account.keys.update',
category: 'security',
level: 'security',
targetType: 'user',
targetId: user.id,
metadata: {
updatedKey: !!body.key,
updatedPrivateKey: !!body.encryptedPrivateKey,
updatedPublicKey: !!body.publicKey,
...auditRequestMetadata(request),
},
});
return handleGetProfile(request, env, userId);
}
@@ -527,14 +566,14 @@ export async function handleChangePassword(request: Request, env: Env, userId: s
await storage.saveUser(user);
await storage.deleteRefreshTokensByUserId(user.id);
AuthService.invalidateUserCache(user.id);
await storage.createAuditLog({
id: generateUUID(),
await writeAuditEvent(storage, {
actorUserId: user.id,
action: 'user.password.change',
targetType: 'user',
targetId: user.id,
metadata: JSON.stringify({ email: user.email }),
createdAt: user.updatedAt,
category: 'security',
level: 'security',
metadata: { email: user.email, ...auditRequestMetadata(request) },
});
return new Response(null, { status: 200 });
@@ -589,6 +628,15 @@ export async function handleSetTotpStatus(request: Request, env: Env, userId: st
await storage.saveUser(user);
await storage.deleteRefreshTokensByUserId(user.id);
AuthService.invalidateUserCache(user.id);
await writeAuditEvent(storage, {
actorUserId: user.id,
action: 'account.totp.enable',
category: 'security',
level: 'security',
targetType: 'user',
targetId: user.id,
metadata: auditRequestMetadata(request),
});
return jsonResponse({ enabled: true, recoveryCode: user.totpRecoveryCode, object: 'twoFactor' });
}
@@ -604,6 +652,15 @@ export async function handleSetTotpStatus(request: Request, env: Env, userId: st
await storage.saveUser(user);
await storage.deleteRefreshTokensByUserId(user.id);
AuthService.invalidateUserCache(user.id);
await writeAuditEvent(storage, {
actorUserId: user.id,
action: 'account.totp.disable',
category: 'security',
level: 'security',
targetType: 'user',
targetId: user.id,
metadata: auditRequestMetadata(request),
});
return jsonResponse({ enabled: false, object: 'twoFactor' });
}
@@ -713,6 +770,15 @@ export async function handleRecoverTwoFactor(request: Request, env: Env): Promis
await storage.deleteRefreshTokensByUserId(user.id);
AuthService.invalidateUserCache(user.id);
await rateLimit.clearLoginAttempts(recoverLimitKey);
await safeWriteAuditEvent(env, {
actorUserId: user.id,
action: 'account.totp.recover',
category: 'security',
level: 'security',
targetType: 'user',
targetId: user.id,
metadata: auditRequestMetadata(request),
});
return jsonResponse({
success: true,
@@ -806,6 +872,15 @@ async function apiKey(request: Request, env: Env, userId: string, rotate: boolea
user.updatedAt = new Date().toISOString();
await storage.saveUser(user);
AuthService.invalidateUserCache(user.id);
await writeAuditEvent(storage, {
actorUserId: user.id,
action: rotate ? 'account.api_key.rotate' : 'account.api_key.create',
category: 'security',
level: rotate ? 'security' : 'info',
targetType: 'user',
targetId: user.id,
metadata: auditRequestMetadata(request),
});
}
return jsonResponse({
+117 -13
View File
@@ -2,8 +2,8 @@ import { Env, User, Invite } from '../types';
import { AuthService } from '../services/auth';
import { StorageService } from '../services/storage';
import { jsonResponse, errorResponse } from '../utils/response';
import { generateUUID } from '../utils/uuid';
import { deleteBlobObject, getAttachmentObjectKey, getSendFileObjectKey } from '../services/blob-store';
import { auditRequestMetadata, getAuditLogSettings, normalizeAuditLogSettings, saveAuditLogSettings, writeAuditEvent } from '../services/audit-events';
function isAdmin(user: User): boolean {
return user.role === 'admin' && user.status === 'active';
@@ -25,16 +25,20 @@ async function writeAuditLog(
action: string,
targetType: string | null,
targetId: string | null,
metadata: Record<string, unknown> | null
metadata: Record<string, unknown> | null,
request?: Request
): Promise<void> {
await storage.createAuditLog({
id: generateUUID(),
await writeAuditEvent(storage, {
actorUserId,
action,
targetType,
targetId,
metadata: metadata ? JSON.stringify(metadata) : null,
createdAt: new Date().toISOString(),
category: action.startsWith('admin.user.') ? 'security' : 'system',
level: action.startsWith('admin.user.') ? 'security' : 'info',
metadata: {
...(metadata || {}),
...(request ? auditRequestMetadata(request) : {}),
},
});
}
@@ -82,6 +86,106 @@ export async function handleAdminListUsers(
});
}
// GET /api/admin/logs
export async function handleAdminListAuditLogs(
request: Request,
env: Env,
actorUser: User
): Promise<Response> {
if (!isAdmin(actorUser)) {
return errorResponse('Forbidden', 403);
}
const url = new URL(request.url);
const limit = Math.max(1, Math.min(200, Number(url.searchParams.get('limit') || 50)));
const offset = Math.max(0, Number(url.searchParams.get('offset') || 0));
const category = String(url.searchParams.get('category') || '').trim() || null;
const level = String(url.searchParams.get('level') || '').trim() || null;
const q = String(url.searchParams.get('q') || '').trim().toLowerCase() || null;
const from = String(url.searchParams.get('from') || '').trim() || null;
const to = String(url.searchParams.get('to') || '').trim() || null;
const storage = new StorageService(env.DB);
const result = await storage.listAuditLogs({ limit, offset, category, level, q, from, to });
return jsonResponse({
data: result.logs.map(log => ({
id: log.id,
actorUserId: log.actorUserId,
actorEmail: log.actorEmail,
action: log.action,
category: log.category,
level: log.level,
targetType: log.targetType,
targetId: log.targetId,
targetUserEmail: log.targetUserEmail,
metadata: log.metadata,
createdAt: log.createdAt,
object: 'auditLog',
})),
total: result.total,
limit,
offset,
hasMore: result.hasMore,
object: 'list',
continuationToken: result.hasMore ? String(offset + result.logs.length) : null,
});
}
// GET /api/admin/logs/settings
export async function handleAdminGetAuditLogSettings(
request: Request,
env: Env,
actorUser: User
): Promise<Response> {
void request;
if (!isAdmin(actorUser)) {
return errorResponse('Forbidden', 403);
}
const storage = new StorageService(env.DB);
return jsonResponse({
object: 'auditLogSettings',
...await getAuditLogSettings(storage),
});
}
// PUT /api/admin/logs/settings
export async function handleAdminUpdateAuditLogSettings(
request: Request,
env: Env,
actorUser: User
): Promise<Response> {
if (!isAdmin(actorUser)) {
return errorResponse('Forbidden', 403);
}
let body: unknown;
try {
body = await request.json();
} catch {
return errorResponse('Invalid JSON', 400);
}
const storage = new StorageService(env.DB);
const settings = await saveAuditLogSettings(storage, normalizeAuditLogSettings(body));
await writeAuditLog(storage, actorUser.id, 'admin.audit.settings.update', 'auditLog', null, { ...settings }, request);
return jsonResponse({
object: 'auditLogSettings',
...settings,
});
}
// DELETE /api/admin/logs
export async function handleAdminClearAuditLogs(
request: Request,
env: Env,
actorUser: User
): Promise<Response> {
if (!isAdmin(actorUser)) {
return errorResponse('Forbidden', 403);
}
const storage = new StorageService(env.DB);
const deleted = await storage.clearAuditLogs();
return jsonResponse({ object: 'auditLogClear', deleted });
}
// POST /api/admin/invites
export async function handleAdminCreateInvite(
request: Request,
@@ -116,9 +220,9 @@ export async function handleAdminCreateInvite(
};
await storage.createInvite(invite);
await writeAuditLog(storage, actorUser.id, 'admin.invite.create', 'invite', invite.code, {
await writeAuditLog(storage, actorUser.id, 'admin.invite.create', 'invite', null, {
expiresInHours,
});
}, request);
return jsonResponse(toInviteResponse(request, invite), 201);
}
@@ -161,7 +265,7 @@ export async function handleAdminRevokeInvite(
return errorResponse('Invite not found or already inactive', 404);
}
await writeAuditLog(storage, actorUser.id, 'admin.invite.revoke', 'invite', code, null);
await writeAuditLog(storage, actorUser.id, 'admin.invite.revoke', 'invite', null, null, request);
return new Response(null, { status: 204 });
}
@@ -180,7 +284,7 @@ export async function handleAdminDeleteAllInvites(
const deleted = await storage.deleteAllInvites();
await writeAuditLog(storage, actorUser.id, 'admin.invite.delete_all', 'invite', null, {
deleted,
});
}, request);
return jsonResponse({ deleted }, 200);
}
@@ -226,7 +330,7 @@ export async function handleAdminSetUserStatus(
AuthService.invalidateUserCache(target.id);
await writeAuditLog(storage, actorUser.id, 'admin.user.status', 'user', target.id, {
status: nextStatus,
});
}, request);
return jsonResponse({
id: target.id,
@@ -284,8 +388,8 @@ export async function handleAdminDeleteUser(
await storage.deleteUserById(target.id);
AuthService.invalidateUserCache(target.id);
await writeAuditLog(storage, actorUser.id, 'admin.user.delete', 'user', target.id, {
email: target.email,
});
targetEmail: target.email,
}, request);
return new Response(null, { status: 204 });
}
+27
View File
@@ -20,6 +20,7 @@ import {
getBlobStorageMaxBytes,
putBlobObject,
} from '../services/blob-store';
import { auditRequestMetadata, writeAuditEvent } from '../services/audit-events';
function notifyVaultSyncForRequest(
request: Request,
@@ -30,6 +31,27 @@ function notifyVaultSyncForRequest(
notifyUserVaultSync(env, userId, revisionDate, readActingDeviceIdentifier(request));
}
async function writeAttachmentAudit(
storage: StorageService,
request: Request,
userId: string,
action: string,
metadata: Record<string, unknown>
): Promise<void> {
await writeAuditEvent(storage, {
actorUserId: userId,
action,
category: 'data',
level: action.includes('delete') ? 'security' : 'info',
targetType: 'attachment',
targetId: typeof metadata.id === 'string' ? metadata.id : null,
metadata: {
...metadata,
...auditRequestMetadata(request),
},
});
}
// Format file size to human readable
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} Bytes`;
@@ -430,6 +452,11 @@ export async function handleDeleteAttachment(
const revisionInfo = await storage.updateCipherRevisionDate(cipherId);
if (revisionInfo) {
notifyVaultSyncForRequest(request, env, revisionInfo.userId, revisionInfo.revisionDate);
await writeAttachmentAudit(storage, request, revisionInfo.userId, 'attachment.delete', {
id: attachmentId,
cipherId,
size: attachment.size,
});
}
// Get updated cipher for response
+22 -13
View File
@@ -40,6 +40,7 @@ import {
uploadBackupArchive,
} from '../services/backup-uploader';
import { StorageService } from '../services/storage';
import { auditRequestMetadata, writeAuditEvent } from '../services/audit-events';
import { getBlobObject } from '../services/blob-store';
import { notifyUserBackupProgress, notifyUserBackupRestoreProgress } from '../durable/notifications-hub';
@@ -53,16 +54,20 @@ async function writeAuditLog(
action: string,
targetType: string | null,
targetId: string | null,
metadata: Record<string, unknown> | null
metadata: Record<string, unknown> | null,
request?: Request
): Promise<void> {
await storage.createAuditLog({
id: generateUUID(),
await writeAuditEvent(storage, {
actorUserId,
action,
targetType,
targetId,
metadata: metadata ? JSON.stringify(metadata) : null,
createdAt: new Date().toISOString(),
category: 'data',
level: action.endsWith('.failed') ? 'error' : 'info',
metadata: {
...(metadata || {}),
...(request ? auditRequestMetadata(request) : {}),
},
});
}
@@ -267,7 +272,8 @@ async function executeConfiguredBackup(
done?: boolean;
ok?: boolean;
error?: string | null;
}) => Promise<void>) | null
}) => Promise<void>) | null,
auditMetadata?: Record<string, unknown> | null
): Promise<{ fileName: string; fileSize: number; remotePath: string; provider: string }> {
const maxArchiveUploadAttempts = 3;
const touchLease = async () => {
@@ -423,6 +429,7 @@ async function executeConfiguredBackup(
uploadVerificationAttempts: maxArchiveUploadAttempts,
prunedFileCount,
pruneError: pruneErrorMessage,
...(auditMetadata || {}),
});
await progress?.({
@@ -451,6 +458,7 @@ async function executeConfiguredBackup(
await writeAuditLog(storage, actorUserId, `admin.backup.remote.${trigger}.failed`, 'backup', null, {
...getBackupDestinationSummary(destination),
error: destination.runtime.lastErrorMessage,
...(auditMetadata || {}),
});
await progress?.({
operation: 'backup-remote-run',
@@ -513,7 +521,7 @@ async function runImportAndAudit(
skippedReason: imported.result.skipped.reason,
replaceExisting,
...metadata,
});
}, request);
return imported;
}
@@ -586,7 +594,7 @@ export async function handleUpdateAdminBackupSettings(request: Request, env: Env
await writeAuditLog(storage, actorUser.id, 'admin.backup.settings.update', 'backup', null, {
destinationCount: next.destinations.length,
scheduledDestinationCount: next.destinations.filter((destination) => destination.schedule.enabled).length,
});
}, request);
return jsonResponse(next);
}
@@ -636,7 +644,7 @@ export async function handleRepairAdminBackupSettings(request: Request, env: Env
await writeAuditLog(storage, actorUser.id, 'admin.backup.settings.repair', 'backup', null, {
destinationCount: next.destinations.length,
scheduledDestinationCount: next.destinations.filter((destination) => destination.schedule.enabled).length,
});
}, request);
return jsonResponse(next);
}
@@ -675,7 +683,8 @@ export async function handleRunAdminConfiguredBackup(request: Request, env: Env,
'manual',
body?.destinationId || null,
keepAlive,
progress
progress,
auditRequestMetadata(request)
);
const settings = await loadBackupSettings(storage, env, 'UTC');
return { result, settings };
@@ -777,7 +786,7 @@ export async function handleDeleteAdminRemoteBackup(request: Request, env: Env,
await writeAuditLog(storage, actorUser.id, 'admin.backup.remote.delete', 'backup', null, {
...getBackupDestinationSummary(destination),
remotePath: path,
});
}, request);
return jsonResponse({ object: 'backup-remote-delete', deleted: true, path });
} catch (error) {
return errorResponse(error instanceof Error ? error.message : 'Remote backup delete failed', 409);
@@ -860,7 +869,7 @@ export async function handleRestoreAdminRemoteBackup(request: Request, env: Env,
bytes: remoteFile.bytes.byteLength,
trigger: 'remote',
checksumMismatchAccepted: !checksumOk,
});
}, request);
return result;
})();
return jsonResponse(imported.result);
@@ -937,7 +946,7 @@ export async function handleAdminExportBackup(request: Request, env: Env, actorU
attachments: archive.manifest.tableCounts.attachments,
compressedBytes: archive.bytes.byteLength,
includesAttachments: archive.manifest.includes.attachments,
});
}, request);
return new Response(archive.bytes, {
status: 200,
+45
View File
@@ -17,6 +17,7 @@ import { generateUUID } from '../utils/uuid';
import { deleteAllAttachmentsForCipher, deleteAllAttachmentsForCiphers } from './attachments';
import { parsePagination, encodeContinuationToken } from '../utils/pagination';
import { readActingDeviceIdentifier } from '../utils/device';
import { auditRequestMetadata, writeAuditEvent } from '../services/audit-events';
// CONTRACT:
// Cipher JSON is the highest-risk Bitwarden compatibility surface. Preserve
@@ -83,6 +84,27 @@ function syncCipherComputedAliases(cipher: Cipher): Cipher {
return cipher;
}
async function writeCipherAudit(
storage: StorageService,
request: Request,
userId: string,
action: string,
metadata: Record<string, unknown>
): Promise<void> {
await writeAuditEvent(storage, {
actorUserId: userId,
action,
category: 'data',
level: action.includes('delete') ? 'security' : 'info',
targetType: 'cipher',
targetId: typeof metadata.id === 'string' ? metadata.id : null,
metadata: {
...metadata,
...auditRequestMetadata(request),
},
});
}
function isValidEncString(value: unknown): value is string {
if (typeof value !== 'string') return false;
const trimmed = value.trim();
@@ -584,6 +606,11 @@ export async function handleDeleteCipher(request: Request, env: Env, userId: str
await storage.saveCipher(cipher);
const revisionDate = await storage.updateRevisionDate(userId);
notifyVaultSyncForRequest(request, env, userId, revisionDate);
await writeCipherAudit(storage, request, userId, 'cipher.delete.soft', {
id: cipher.id,
type: cipher.type,
folderId: cipher.folderId ?? null,
});
return jsonResponse(
cipherToResponse(cipher, [])
@@ -608,6 +635,12 @@ export async function handleDeleteCipherCompat(request: Request, env: Env, userI
await storage.deleteCipher(id, userId);
const revisionDate = await storage.updateRevisionDate(userId);
notifyVaultSyncForRequest(request, env, userId, revisionDate);
await writeCipherAudit(storage, request, userId, 'cipher.delete.permanent', {
id,
type: cipher.type,
folderId: cipher.folderId ?? null,
compat: true,
});
return new Response(null, { status: 204 });
}
@@ -629,6 +662,11 @@ export async function handlePermanentDeleteCipher(request: Request, env: Env, us
await storage.deleteCipher(id, userId);
const revisionDate = await storage.updateRevisionDate(userId);
notifyVaultSyncForRequest(request, env, userId, revisionDate);
await writeCipherAudit(storage, request, userId, 'cipher.delete.permanent', {
id,
type: cipher.type,
folderId: cipher.folderId ?? null,
});
return new Response(null, { status: 204 });
}
@@ -858,6 +896,9 @@ export async function handleBulkDeleteCiphers(request: Request, env: Env, userId
const revisionDate = await storage.bulkSoftDeleteCiphers(body.ids, userId);
if (revisionDate) {
notifyVaultSyncForRequest(request, env, userId, revisionDate);
await writeCipherAudit(storage, request, userId, 'cipher.delete.soft.bulk', {
count: body.ids.length,
});
}
return new Response(null, { status: 204 });
@@ -917,6 +958,10 @@ export async function handleBulkPermanentDeleteCiphers(request: Request, env: En
const revisionDate = await storage.bulkDeleteCiphers(ownedIds, userId);
if (revisionDate) {
notifyVaultSyncForRequest(request, env, userId, revisionDate);
await writeCipherAudit(storage, request, userId, 'cipher.delete.permanent.bulk', {
count: ownedIds.length,
requestedCount: ids.length,
});
}
return new Response(null, { status: 204 });
+64
View File
@@ -2,6 +2,7 @@ import type { Device, DevicePendingAuthRequest, DeviceResponse, ProtectedDeviceR
import { Env } from '../types';
import { getOnlineUserDevices, notifyUserLogout } from '../durable/notifications-hub';
import { AuthService } from '../services/auth';
import { auditRequestMetadata, writeAuditEvent } from '../services/audit-events';
import { StorageService } from '../services/storage';
import { errorResponse, jsonResponse } from '../utils/response';
import { readKnownDeviceProbe } from '../utils/device';
@@ -268,6 +269,15 @@ export async function handleRevokeTrustedDevice(
const storage = new StorageService(env.DB);
const removed = await storage.deleteTrustedTwoFactorTokensByDevice(userId, normalized);
await writeAuditEvent(storage, {
actorUserId: userId,
action: 'device.trust.revoke',
category: 'device',
level: 'security',
targetType: 'device',
targetId: normalized,
metadata: { removed, ...auditRequestMetadata(request) },
});
return jsonResponse({ success: true, removed });
}
@@ -286,6 +296,15 @@ export async function handleTrustDevicePermanently(
const storage = new StorageService(env.DB);
const updated = await storage.updateTrustedTwoFactorTokensExpiryByDevice(userId, normalized, PERMANENT_TRUST_EXPIRES_AT_MS);
if (!updated) return errorResponse('Device is not currently trusted', 409);
await writeAuditEvent(storage, {
actorUserId: userId,
action: 'device.trust.permanent',
category: 'device',
level: 'security',
targetType: 'device',
targetId: normalized,
metadata: { updated, ...auditRequestMetadata(request) },
});
return jsonResponse({
success: true,
@@ -313,6 +332,15 @@ export async function handleDeleteDevice(
AuthService.invalidateDeviceCache(userId, normalized);
notifyUserLogout(env, userId, normalized);
}
await writeAuditEvent(storage, {
actorUserId: userId,
action: 'device.delete',
category: 'device',
level: 'security',
targetType: 'device',
targetId: normalized,
metadata: { deleted, ...auditRequestMetadata(request) },
});
return jsonResponse({ success: deleted });
}
@@ -336,6 +364,15 @@ export async function handleUpdateDeviceName(
const device = await storage.getDevice(userId, normalized);
if (!device) return errorResponse('Device not found', 404);
await writeAuditEvent(storage, {
actorUserId: userId,
action: 'device.name.update',
category: 'device',
level: 'info',
targetType: 'device',
targetId: normalized,
metadata: { name, ...auditRequestMetadata(request) },
});
return jsonResponse(buildDeviceResponse(device));
}
@@ -356,6 +393,15 @@ export async function handleDeleteAllDevices(request: Request, env: Env, userId:
await storage.saveUser(user);
AuthService.invalidateUserCache(userId);
notifyUserLogout(env, userId, null);
await writeAuditEvent(storage, {
actorUserId: userId,
action: 'device.delete_all',
category: 'device',
level: 'security',
targetType: 'user',
targetId: userId,
metadata: { removedTrusted, removedSessions, removedDevices, ...auditRequestMetadata(request) },
});
return jsonResponse({ success: true, removedTrusted, removedSessions: removedSessions ?? 0, removedDevices });
}
@@ -447,6 +493,15 @@ export async function handleUntrustDevices(
if (!deviceIdentifier) continue;
await storage.deleteTrustedTwoFactorTokensByDevice(userId, deviceIdentifier);
}
await writeAuditEvent(storage, {
actorUserId: userId,
action: 'device.trust.revoke_batch',
category: 'device',
level: 'security',
targetType: 'user',
targetId: userId,
metadata: { requested: devices.length, removed, ...auditRequestMetadata(request) },
});
return jsonResponse({ success: true, removed });
}
@@ -489,6 +544,15 @@ export async function handleDeactivateDevice(
AuthService.invalidateDeviceCache(userId, normalized);
notifyUserLogout(env, userId, normalized);
}
await writeAuditEvent(storage, {
actorUserId: userId,
action: 'device.deactivate',
category: 'device',
level: 'security',
targetType: 'device',
targetId: normalized,
metadata: { deleted, ...auditRequestMetadata(request) },
});
return jsonResponse({ success: deleted });
}
+28
View File
@@ -5,6 +5,7 @@ import { jsonResponse, errorResponse } from '../utils/response';
import { readActingDeviceIdentifier } from '../utils/device';
import { generateUUID } from '../utils/uuid';
import { parsePagination, encodeContinuationToken } from '../utils/pagination';
import { auditRequestMetadata, writeAuditEvent } from '../services/audit-events';
function notifyVaultSyncForRequest(
request: Request,
@@ -15,6 +16,27 @@ function notifyVaultSyncForRequest(
notifyUserVaultSync(env, userId, revisionDate, readActingDeviceIdentifier(request));
}
async function writeFolderAudit(
storage: StorageService,
request: Request,
userId: string,
action: string,
metadata: Record<string, unknown>
): Promise<void> {
await writeAuditEvent(storage, {
actorUserId: userId,
action,
category: 'data',
level: action.includes('delete') ? 'security' : 'info',
targetType: 'folder',
targetId: typeof metadata.id === 'string' ? metadata.id : null,
metadata: {
...metadata,
...auditRequestMetadata(request),
},
});
}
// Convert internal folder to API response format
function folderToResponse(folder: Folder): FolderResponse {
return {
@@ -134,6 +156,9 @@ export async function handleDeleteFolder(request: Request, env: Env, userId: str
await storage.deleteFolder(id, userId);
const revisionDate = await storage.updateRevisionDate(userId);
notifyVaultSyncForRequest(request, env, userId, revisionDate);
await writeFolderAudit(storage, request, userId, 'folder.delete', {
id,
});
return new Response(null, { status: 204 });
}
@@ -157,6 +182,9 @@ export async function handleBulkDeleteFolders(request: Request, env: Env, userId
const revisionDate = await storage.bulkDeleteFolders(ids, userId);
if (revisionDate) {
notifyVaultSyncForRequest(request, env, userId, revisionDate);
await writeFolderAudit(storage, request, userId, 'folder.delete.bulk', {
count: ids.length,
});
}
return new Response(null, { status: 204 });
+99 -2
View File
@@ -14,6 +14,7 @@ import {
buildAccountKeys,
buildUserDecryptionOptions,
} from '../utils/user-decryption';
import { auditRequestMetadata, safeWriteAuditEvent } from '../services/audit-events';
const TWO_FACTOR_REMEMBER_TTL_MS = 30 * 24 * 60 * 60 * 1000;
const TWO_FACTOR_PROVIDER_AUTHENTICATOR = 0;
@@ -251,11 +252,37 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
}
if (user.status !== 'active') {
await rateLimit.recordFailedLogin(loginIdentifier);
await safeWriteAuditEvent(env, {
actorUserId: user.id,
action: 'auth.login.failed.user_inactive',
category: 'auth',
level: 'warn',
targetType: 'user',
targetId: user.id,
metadata: {
grantType,
deviceIdentifier: deviceInfo.deviceIdentifier,
...auditRequestMetadata(request),
},
});
return identityErrorResponse('Account is disabled', 'invalid_grant', 400);
}
const valid = await auth.verifyPassword(passwordHash, user.masterPasswordHash, user.email);
if (!valid) {
await safeWriteAuditEvent(env, {
actorUserId: user.id,
action: 'auth.login.failed.bad_password',
category: 'auth',
level: 'warn',
targetType: 'user',
targetId: user.id,
metadata: {
grantType,
deviceIdentifier: deviceInfo.deviceIdentifier,
...auditRequestMetadata(request),
},
});
return recordFailedLoginAndBuildResponse(
rateLimit,
loginIdentifier,
@@ -349,6 +376,21 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
const refreshToken = await auth.generateRefreshToken(user.id, deviceSession);
const accountKeys = buildAccountKeys(user);
const userDecryptionOptions = buildUserDecryptionOptions(user);
await safeWriteAuditEvent(env, {
actorUserId: user.id,
action: 'auth.login.success',
category: 'auth',
level: 'info',
targetType: 'user',
targetId: user.id,
metadata: {
grantType,
webSession: shouldUseWebSession(request),
deviceIdentifier: deviceSession?.identifier ?? deviceInfo.deviceIdentifier,
deviceType: deviceInfo.deviceType,
...auditRequestMetadata(request),
},
});
const response: TokenResponse = {
access_token: accessToken,
@@ -412,11 +454,37 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
}
if (user.status !== 'active') {
await rateLimit.recordFailedLogin(loginIdentifier);
await safeWriteAuditEvent(env, {
actorUserId: user.id,
action: 'auth.login.failed.user_inactive',
category: 'auth',
level: 'warn',
targetType: 'user',
targetId: user.id,
metadata: {
grantType,
deviceIdentifier: deviceInfo.deviceIdentifier,
...auditRequestMetadata(request),
},
});
return identityErrorResponse('Account is disabled', 'invalid_grant', 400);
}
if (!user.apiKey || !constantTimeEquals(clientSecret, user.apiKey)) {
await rateLimit.recordFailedLogin(loginIdentifier);
await safeWriteAuditEvent(env, {
actorUserId: user.id,
action: 'auth.login.failed.bad_api_key',
category: 'auth',
level: 'warn',
targetType: 'user',
targetId: user.id,
metadata: {
grantType,
deviceIdentifier: deviceInfo.deviceIdentifier,
...auditRequestMetadata(request),
},
});
return identityErrorResponse('ClientId or clientSecret is incorrect. Try again', 'invalid_grant', 400);
}
@@ -439,6 +507,21 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
const refreshToken = await auth.generateRefreshToken(user.id, deviceSession);
const accountKeys = buildAccountKeys(user);
const userDecryptionOptions = buildUserDecryptionOptions(user);
await safeWriteAuditEvent(env, {
actorUserId: user.id,
action: 'auth.login.success',
category: 'auth',
level: 'info',
targetType: 'user',
targetId: user.id,
metadata: {
grantType,
webSession: shouldUseWebSession(request),
deviceIdentifier: deviceSession?.identifier ?? deviceInfo.deviceIdentifier,
deviceType: deviceInfo.deviceType,
...auditRequestMetadata(request),
},
});
const response: TokenResponse = {
access_token: accessToken,
@@ -543,8 +626,22 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
return identityErrorResponse('Refresh token is required', 'invalid_request', 400);
}
const result = await auth.refreshAccessToken(refreshToken);
if (!result) {
const result = await auth.refreshAccessTokenDetailed(refreshToken);
if (!result.ok) {
await safeWriteAuditEvent(env, {
actorUserId: result.userId ?? null,
action: `auth.refresh.failed.${result.reason}`,
category: 'auth',
level: 'warn',
targetType: result.deviceIdentifier ? 'device' : 'refreshToken',
targetId: result.deviceIdentifier ?? null,
metadata: {
grantType,
reason: result.reason,
webSession: shouldUseWebSession(request),
...auditRequestMetadata(request),
},
});
const invalidResponse = identityErrorResponse('Invalid refresh token', 'invalid_grant', 400);
return shouldUseWebSession(request)
? withWebRefreshCookie(request, invalidResponse, null)
+38 -3
View File
@@ -29,6 +29,28 @@ import {
setSendPassword,
validateDeletionDate,
} from './sends-shared';
import { auditRequestMetadata, writeAuditEvent } from '../services/audit-events';
async function writeSendAudit(
storage: StorageService,
request: Request,
userId: string,
action: string,
metadata: Record<string, unknown>
): Promise<void> {
await writeAuditEvent(storage, {
actorUserId: userId,
action,
category: 'data',
level: action.includes('delete') ? 'security' : 'info',
targetType: 'send',
targetId: typeof metadata.id === 'string' ? metadata.id : null,
metadata: {
...metadata,
...auditRequestMetadata(request),
},
});
}
async function processSendFileUpload(
request: Request,
@@ -602,7 +624,6 @@ export async function handleUpdateSend(request: Request, env: Env, userId: strin
}
export async function handleDeleteSend(request: Request, env: Env, userId: string, sendId: string): Promise<Response> {
void request;
const storage = new StorageService(env.DB);
const send = await storage.getSend(sendId);
if (!send || send.userId !== userId) {
@@ -620,6 +641,10 @@ export async function handleDeleteSend(request: Request, env: Env, userId: strin
await storage.deleteSend(sendId, userId);
const revisionDate = await storage.updateRevisionDate(userId);
notifyVaultSyncForRequest(request, env, userId, revisionDate);
await writeSendAudit(storage, request, userId, 'send.delete', {
id: sendId,
type: send.type,
});
return new Response(null, { status: 200 });
}
@@ -651,13 +676,16 @@ export async function handleBulkDeleteSends(request: Request, env: Env, userId:
const revisionDate = await storage.bulkDeleteSends(body.ids, userId);
if (revisionDate) {
notifyVaultSyncForRequest(request, env, userId, revisionDate);
await writeSendAudit(storage, request, userId, 'send.delete.bulk', {
count: sends.length,
requestedCount: body.ids.length,
});
}
return new Response(null, { status: 200 });
}
export async function handleRemoveSendPassword(request: Request, env: Env, userId: string, sendId: string): Promise<Response> {
void request;
const storage = new StorageService(env.DB);
const send = await storage.getSend(sendId);
if (!send || send.userId !== userId) {
@@ -669,12 +697,15 @@ export async function handleRemoveSendPassword(request: Request, env: Env, userI
await storage.saveSend(send);
const revisionDate = await storage.updateRevisionDate(userId);
notifyVaultSyncForRequest(request, env, userId, revisionDate);
await writeSendAudit(storage, request, userId, 'send.password.remove', {
id: send.id,
type: send.type,
});
return jsonResponse(sendToResponse(send));
}
export async function handleRemoveSendAuth(request: Request, env: Env, userId: string, sendId: string): Promise<Response> {
void request;
const storage = new StorageService(env.DB);
const send = await storage.getSend(sendId);
if (!send || send.userId !== userId) {
@@ -687,6 +718,10 @@ export async function handleRemoveSendAuth(request: Request, env: Env, userId: s
await storage.saveSend(send);
const revisionDate = await storage.updateRevisionDate(userId);
notifyVaultSyncForRequest(request, env, userId, revisionDate);
await writeSendAudit(storage, request, userId, 'send.auth.remove', {
id: send.id,
type: send.type,
});
return jsonResponse(sendToResponse(send));
}