From 6e95d7a235b404d79da6a45132a09fd3ef178625 Mon Sep 17 00:00:00 2001 From: shuaiplus <2327005759@qq.com> Date: Thu, 26 Feb 2026 04:12:45 +0800 Subject: [PATCH] feat: implement admin user management and invite system --- migrations/0001_init.sql | 30 ++ src/handlers/accounts.ts | 308 ++++++++++++++---- src/handlers/admin.ts | 245 ++++++++++++++ src/handlers/identity.ts | 21 +- src/handlers/setup.ts | 2 +- src/handlers/sync.ts | 2 +- src/handlers/web.ts | 672 +++++++++++++++++++++++++++++++++++++++ src/router.ts | 85 ++++- src/services/auth.ts | 4 + src/services/storage.ts | 199 ++++++++++-- src/types/index.ts | 28 ++ 11 files changed, 1491 insertions(+), 105 deletions(-) create mode 100644 src/handlers/admin.ts create mode 100644 src/handlers/web.ts diff --git a/migrations/0001_init.sql b/migrations/0001_init.sql index 1970926..bae2908 100644 --- a/migrations/0001_init.sql +++ b/migrations/0001_init.sql @@ -22,6 +22,9 @@ CREATE TABLE IF NOT EXISTS users ( kdf_memory INTEGER, kdf_parallelism INTEGER, security_stamp TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'user', + status TEXT NOT NULL DEFAULT 'active', + totp_secret TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL ); @@ -81,6 +84,33 @@ CREATE TABLE IF NOT EXISTS refresh_tokens ( ); CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user ON refresh_tokens(user_id); +CREATE TABLE IF NOT EXISTS invites ( + code TEXT PRIMARY KEY, + created_by TEXT NOT NULL, + used_by TEXT, + expires_at TEXT NOT NULL, + status TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY (used_by) REFERENCES users(id) ON DELETE SET NULL +); +CREATE INDEX IF NOT EXISTS idx_invites_status_expires ON invites(status, expires_at); +CREATE INDEX IF NOT EXISTS idx_invites_created_by ON invites(created_by, created_at); + +CREATE TABLE IF NOT EXISTS audit_logs ( + id TEXT PRIMARY KEY, + actor_user_id TEXT, + action TEXT NOT NULL, + target_type TEXT, + target_id TEXT, + metadata TEXT, + created_at TEXT NOT NULL, + FOREIGN KEY (actor_user_id) REFERENCES users(id) ON DELETE SET NULL +); +CREATE INDEX IF NOT EXISTS idx_audit_logs_created_at ON audit_logs(created_at); +CREATE INDEX IF NOT EXISTS idx_audit_logs_actor_created ON audit_logs(actor_user_id, created_at); + CREATE TABLE IF NOT EXISTS devices ( user_id TEXT NOT NULL, device_identifier TEXT NOT NULL, diff --git a/src/handlers/accounts.ts b/src/handlers/accounts.ts index bf445fb..6306cc8 100644 --- a/src/handlers/accounts.ts +++ b/src/handlers/accounts.ts @@ -4,7 +4,7 @@ import { AuthService } from '../services/auth'; import { jsonResponse, errorResponse } from '../utils/response'; import { generateUUID } from '../utils/uuid'; import { LIMITS } from '../config/limits'; -import { isTotpEnabled } from '../utils/totp'; +import { isTotpEnabled, verifyTotpToken } from '../utils/totp'; function looksLikeEncString(value: string): boolean { if (!value) return false; @@ -16,6 +16,10 @@ function looksLikeEncString(value: string): boolean { return parts.length >= 2; } +function normalizeTotpSecret(input: string): string { + return input.toUpperCase().replace(/[\s-]/g, '').replace(/=+$/g, ''); +} + function jwtSecretUnsafeReason(env: Env): 'missing' | 'default' | 'too_short' | null { const secret = (env.JWT_SECRET || '').trim(); if (!secret) return 'missing'; @@ -24,11 +28,40 @@ function jwtSecretUnsafeReason(env: Env): 'missing' | 'default' | 'too_short' | return null; } -// POST /api/accounts/register (only used from setup page, not client) +function toProfile(user: User, env: Env): ProfileResponse { + return { + id: user.id, + name: user.name, + email: user.email, + emailVerified: true, + premium: true, + premiumFromOrganization: false, + usesKeyConnector: false, + masterPasswordHint: null, + culture: 'en-US', + twoFactorEnabled: !!user.totpSecret || isTotpEnabled(env.TOTP_SECRET), + key: user.key, + privateKey: user.privateKey, + accountKeys: null, + securityStamp: user.securityStamp || user.id, + organizations: [], + providers: [], + providerOrganizations: [], + forcePasswordReset: false, + avatarColor: null, + creationDate: user.createdAt, + role: user.role, + status: user.status, + object: 'profile', + }; +} + +// POST /api/accounts/register +// - First user becomes admin. +// - Any subsequent user must provide a valid inviteCode. export async function handleRegister(request: Request, env: Env): Promise { const storage = new StorageService(env.DB); - // Enforce safe JWT_SECRET before allowing first registration. const unsafe = jwtSecretUnsafeReason(env); if (unsafe) { const message = unsafe === 'missing' @@ -43,12 +76,12 @@ export async function handleRegister(request: Request, env: Env): Promise { + void request; const storage = new StorageService(env.DB); const user = await storage.getUserById(userId); - - if (!user) { - return errorResponse('User not found', 404); - } - - const profile: ProfileResponse = { - id: user.id, - name: user.name, - email: user.email, - emailVerified: true, - premium: true, - premiumFromOrganization: false, - usesKeyConnector: false, - masterPasswordHint: null, - culture: 'en-US', - twoFactorEnabled: isTotpEnabled(env.TOTP_SECRET), - key: user.key, - privateKey: user.privateKey, - accountKeys: null, - securityStamp: user.securityStamp || user.id, - organizations: [], - providers: [], - providerOrganizations: [], - forcePasswordReset: false, - avatarColor: null, - creationDate: user.createdAt, - object: 'profile', - }; - - return jsonResponse(profile); + if (!user) return errorResponse('User not found', 404); + return jsonResponse(toProfile(user, env)); } // PUT /api/accounts/profile export async function handleUpdateProfile(request: Request, env: Env, userId: string): Promise { const storage = new StorageService(env.DB); const user = await storage.getUserById(userId); + if (!user) return errorResponse('User not found', 404); - if (!user) { - return errorResponse('User not found', 404); - } - - let body: { name?: string; masterPasswordHint?: string }; + let body: { name?: string; email?: string }; try { body = await request.json(); } catch { return errorResponse('Invalid JSON', 400); } - if (body.name) { - user.name = body.name; + if (typeof body.name === 'string') { + user.name = body.name.trim() || user.name; + } + if (typeof body.email === 'string') { + const normalized = body.email.trim().toLowerCase(); + if (!normalized) return errorResponse('Email is required', 400); + user.email = normalized; } user.updatedAt = new Date().toISOString(); - await storage.saveUser(user); + try { + await storage.saveUser(user); + } catch (error) { + const msg = error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase(); + if (msg.includes('unique') || msg.includes('constraint')) { + return errorResponse('Email already registered', 409); + } + throw error; + } return handleGetProfile(request, env, userId); } @@ -209,11 +271,139 @@ export async function handleSetKeys(request: Request, env: Env, userId: string): return handleGetProfile(request, env, userId); } +// POST/PUT /api/accounts/password +export async function handleChangePassword(request: Request, env: Env, userId: string): Promise { + const storage = new StorageService(env.DB); + const auth = new AuthService(env); + const user = await storage.getUserById(userId); + if (!user) return errorResponse('User not found', 404); + + let body: { + masterPasswordHash?: string; + currentPasswordHash?: string; + newMasterPasswordHash?: string; + key?: string; + newKey?: string; + encryptedPrivateKey?: string; + newEncryptedPrivateKey?: string; + publicKey?: string; + newPublicKey?: string; + kdf?: number; + kdfIterations?: number; + kdfMemory?: number; + kdfParallelism?: number; + }; + try { + body = await request.json(); + } catch { + return errorResponse('Invalid JSON', 400); + } + + const currentHash = body.currentPasswordHash || body.masterPasswordHash; + if (!currentHash) return errorResponse('Current password hash is required', 400); + const valid = await auth.verifyPassword(currentHash, user.masterPasswordHash); + if (!valid) return errorResponse('Invalid password', 400); + + if (!body.newMasterPasswordHash) { + return errorResponse('newMasterPasswordHash is required', 400); + } + const nextKey = body.newKey || body.key; + const nextPrivateKey = body.newEncryptedPrivateKey || body.encryptedPrivateKey; + const nextPublicKey = body.newPublicKey || body.publicKey; + if (nextKey && !looksLikeEncString(nextKey)) { + return errorResponse('new key is not a valid encrypted string', 400); + } + if (nextPrivateKey && !looksLikeEncString(nextPrivateKey)) { + return errorResponse('new encryptedPrivateKey is not a valid encrypted string', 400); + } + + user.masterPasswordHash = body.newMasterPasswordHash; + if (nextKey) user.key = nextKey; + if (nextPrivateKey) user.privateKey = nextPrivateKey; + if (nextPublicKey) user.publicKey = nextPublicKey; + if (typeof body.kdf === 'number') user.kdfType = body.kdf; + if (typeof body.kdfIterations === 'number') user.kdfIterations = body.kdfIterations; + if (typeof body.kdfMemory === 'number') user.kdfMemory = body.kdfMemory; + if (typeof body.kdfParallelism === 'number') user.kdfParallelism = body.kdfParallelism; + user.securityStamp = generateUUID(); + user.updatedAt = new Date().toISOString(); + await storage.saveUser(user); + await storage.deleteRefreshTokensByUserId(user.id); + + return new Response(null, { status: 200 }); +} + +// GET /api/accounts/totp +export async function handleGetTotpStatus(request: Request, env: Env, userId: string): Promise { + void request; + const storage = new StorageService(env.DB); + const user = await storage.getUserById(userId); + if (!user) return errorResponse('User not found', 404); + + return jsonResponse({ + enabled: !!user.totpSecret, + object: 'twoFactor', + }); +} + +// PUT /api/accounts/totp +// enable: { enabled: true, secret: "...", token: "123456" } +// disable: { enabled: false, masterPasswordHash: "..." } +export async function handleSetTotpStatus(request: Request, env: Env, userId: string): Promise { + const storage = new StorageService(env.DB); + const auth = new AuthService(env); + const user = await storage.getUserById(userId); + if (!user) return errorResponse('User not found', 404); + + let body: { enabled?: boolean; secret?: string; token?: string; masterPasswordHash?: string }; + try { + body = await request.json(); + } catch { + return errorResponse('Invalid JSON', 400); + } + + if (body.enabled === true) { + const normalizedSecret = normalizeTotpSecret(body.secret || ''); + if (!isTotpEnabled(normalizedSecret)) { + return errorResponse('Invalid TOTP secret', 400); + } + if (!body.token) { + return errorResponse('TOTP token is required', 400); + } + const verified = await verifyTotpToken(normalizedSecret, body.token); + if (!verified) { + return errorResponse('Invalid TOTP token', 400); + } + user.totpSecret = normalizedSecret; + user.updatedAt = new Date().toISOString(); + await storage.saveUser(user); + await storage.deleteRefreshTokensByUserId(user.id); + return jsonResponse({ enabled: true, object: 'twoFactor' }); + } + + if (body.enabled === false) { + if (!body.masterPasswordHash) { + return errorResponse('masterPasswordHash is required to disable TOTP', 400); + } + const valid = await auth.verifyPassword(body.masterPasswordHash, user.masterPasswordHash); + if (!valid) return errorResponse('Invalid password', 400); + + user.totpSecret = null; + user.updatedAt = new Date().toISOString(); + await storage.saveUser(user); + await storage.deleteRefreshTokensByUserId(user.id); + return jsonResponse({ enabled: false, object: 'twoFactor' }); + } + + return errorResponse('enabled must be true or false', 400); +} + // GET /api/accounts/revision-date export async function handleGetRevisionDate(request: Request, env: Env, userId: string): Promise { + void request; const storage = new StorageService(env.DB); const revisionDate = await storage.getRevisionDate(userId); - + // Return as milliseconds timestamp (Bitwarden format) const timestamp = new Date(revisionDate).getTime(); return jsonResponse(timestamp); diff --git a/src/handlers/admin.ts b/src/handlers/admin.ts new file mode 100644 index 0000000..85880ed --- /dev/null +++ b/src/handlers/admin.ts @@ -0,0 +1,245 @@ +import { Env, User, Invite } from '../types'; +import { StorageService } from '../services/storage'; +import { jsonResponse, errorResponse } from '../utils/response'; +import { generateUUID } from '../utils/uuid'; + +function isAdmin(user: User): boolean { + return user.role === 'admin' && user.status === 'active'; +} + +function randomHex(bytes: number): string { + const data = crypto.getRandomValues(new Uint8Array(bytes)); + return Array.from(data).map(v => v.toString(16).padStart(2, '0')).join(''); +} + +function buildInviteLink(request: Request, code: string): string { + const url = new URL(request.url); + return `${url.origin}/?invite=${encodeURIComponent(code)}`; +} + +async function writeAuditLog( + storage: StorageService, + actorUserId: string | null, + action: string, + targetType: string | null, + targetId: string | null, + metadata: Record | null +): Promise { + await storage.createAuditLog({ + id: generateUUID(), + actorUserId, + action, + targetType, + targetId, + metadata: metadata ? JSON.stringify(metadata) : null, + createdAt: new Date().toISOString(), + }); +} + +function toInviteResponse(request: Request, invite: Invite): Record { + return { + code: invite.code, + status: invite.status, + createdBy: invite.createdBy, + usedBy: invite.usedBy, + createdAt: invite.createdAt, + updatedAt: invite.updatedAt, + expiresAt: invite.expiresAt, + inviteLink: buildInviteLink(request, invite.code), + object: 'invite', + }; +} + +// GET /api/admin/users +export async function handleAdminListUsers( + request: Request, + env: Env, + actorUser: User +): Promise { + void request; + if (!isAdmin(actorUser)) { + return errorResponse('Forbidden', 403); + } + + const storage = new StorageService(env.DB); + const users = await storage.getAllUsers(); + return jsonResponse({ + data: users.map(user => ({ + id: user.id, + email: user.email, + name: user.name, + role: user.role, + status: user.status, + twoFactorEnabled: !!user.totpSecret, + creationDate: user.createdAt, + revisionDate: user.updatedAt, + object: 'user', + })), + object: 'list', + continuationToken: null, + }); +} + +// POST /api/admin/invites +export async function handleAdminCreateInvite( + request: Request, + env: Env, + actorUser: User +): Promise { + if (!isAdmin(actorUser)) { + return errorResponse('Forbidden', 403); + } + + const storage = new StorageService(env.DB); + let body: { expiresInHours?: number } = {}; + try { + body = await request.json(); + } catch { + body = {}; + } + + const expiresInHours = Number.isFinite(body.expiresInHours) + ? Math.max(1, Math.min(24 * 30, Math.floor(Number(body.expiresInHours)))) + : 24 * 7; + const now = new Date(); + const expiresAt = new Date(now.getTime() + expiresInHours * 60 * 60 * 1000); + const invite: Invite = { + code: randomHex(20), + createdBy: actorUser.id, + usedBy: null, + expiresAt: expiresAt.toISOString(), + status: 'active', + createdAt: now.toISOString(), + updatedAt: now.toISOString(), + }; + + await storage.createInvite(invite); + await writeAuditLog(storage, actorUser.id, 'admin.invite.create', 'invite', invite.code, { + expiresInHours, + }); + + return jsonResponse(toInviteResponse(request, invite), 201); +} + +// GET /api/admin/invites +export async function handleAdminListInvites( + request: Request, + env: Env, + actorUser: User +): Promise { + if (!isAdmin(actorUser)) { + return errorResponse('Forbidden', 403); + } + + const storage = new StorageService(env.DB); + const url = new URL(request.url); + const includeInactive = url.searchParams.get('includeInactive') === 'true'; + const invites = await storage.listInvites(includeInactive); + return jsonResponse({ + data: invites.map(invite => toInviteResponse(request, invite)), + object: 'list', + continuationToken: null, + }); +} + +// DELETE /api/admin/invites/:code +export async function handleAdminRevokeInvite( + request: Request, + env: Env, + actorUser: User, + code: string +): Promise { + if (!isAdmin(actorUser)) { + return errorResponse('Forbidden', 403); + } + + const storage = new StorageService(env.DB); + const revoked = await storage.revokeInvite(code); + if (!revoked) { + return errorResponse('Invite not found or already inactive', 404); + } + + await writeAuditLog(storage, actorUser.id, 'admin.invite.revoke', 'invite', code, null); + return new Response(null, { status: 204 }); +} + +// PUT /api/admin/users/:id/status +export async function handleAdminSetUserStatus( + request: Request, + env: Env, + actorUser: User, + targetUserId: string +): Promise { + if (!isAdmin(actorUser)) { + return errorResponse('Forbidden', 403); + } + + let body: { status?: string }; + try { + body = await request.json(); + } catch { + return errorResponse('Invalid JSON', 400); + } + + const nextStatus = body.status === 'banned' ? 'banned' : body.status === 'active' ? 'active' : null; + if (!nextStatus) { + return errorResponse('status must be active or banned', 400); + } + if (targetUserId === actorUser.id && nextStatus !== 'active') { + return errorResponse('You cannot ban yourself', 400); + } + + const storage = new StorageService(env.DB); + const target = await storage.getUserById(targetUserId); + if (!target) { + return errorResponse('User not found', 404); + } + + target.status = nextStatus; + target.updatedAt = new Date().toISOString(); + await storage.saveUser(target); + if (nextStatus === 'banned') { + await storage.deleteRefreshTokensByUserId(target.id); + } + await writeAuditLog(storage, actorUser.id, 'admin.user.status', 'user', target.id, { + status: nextStatus, + }); + + return jsonResponse({ + id: target.id, + email: target.email, + role: target.role, + status: target.status, + object: 'user', + }); +} + +// DELETE /api/admin/users/:id +export async function handleAdminDeleteUser( + request: Request, + env: Env, + actorUser: User, + targetUserId: string +): Promise { + void request; + if (!isAdmin(actorUser)) { + return errorResponse('Forbidden', 403); + } + if (targetUserId === actorUser.id) { + return errorResponse('You cannot delete yourself', 400); + } + + const storage = new StorageService(env.DB); + const target = await storage.getUserById(targetUserId); + if (!target) { + return errorResponse('User not found', 404); + } + + await storage.deleteRefreshTokensByUserId(target.id); + await storage.deleteUserById(target.id); + await writeAuditLog(storage, actorUser.id, 'admin.user.delete', 'user', target.id, { + email: target.email, + }); + + return new Response(null, { status: 204 }); +} diff --git a/src/handlers/identity.ts b/src/handlers/identity.ts index 482d306..78c0006 100644 --- a/src/handlers/identity.ts +++ b/src/handlers/identity.ts @@ -12,6 +12,16 @@ const TWO_FACTOR_REMEMBER_TTL_MS = 30 * 24 * 60 * 60 * 1000; const TWO_FACTOR_PROVIDER_AUTHENTICATOR = 0; const TWO_FACTOR_PROVIDER_REMEMBER = 5; +function resolveTotpSecret(userSecret: string | null, envSecret: string | undefined): string | null { + if (userSecret && isTotpEnabled(userSecret)) { + return userSecret; + } + if (isTotpEnabled(envSecret)) { + return envSecret!; + } + return null; +} + function twoFactorRequiredResponse(message: string = 'Two factor required.'): Response { // Bitwarden clients rely on these fields to trigger the 2FA UI flow. return jsonResponse( @@ -119,6 +129,10 @@ export async function handleToken(request: Request, env: Env): Promise await rateLimit.recordFailedLogin(loginIdentifier); return identityErrorResponse('Username or password is incorrect. Try again', 'invalid_grant', 400); } + if (user.status !== 'active') { + await rateLimit.recordFailedLogin(loginIdentifier); + return identityErrorResponse('Account is disabled', 'invalid_grant', 400); + } const valid = await auth.verifyPassword(passwordHash, user.masterPasswordHash); if (!valid) { @@ -129,9 +143,10 @@ export async function handleToken(request: Request, env: Env): Promise ); } - // Optional 2FA: enabled only when TOTP_SECRET is configured in Workers env. + // Optional 2FA: enabled per-user secret first, then falls back to global env secret for compatibility. let trustedTwoFactorTokenToReturn: string | undefined; - if (isTotpEnabled(env.TOTP_SECRET)) { + const effectiveTotpSecret = resolveTotpSecret(user.totpSecret, env.TOTP_SECRET); + if (effectiveTotpSecret) { const normalizedTwoFactorProvider = String(twoFactorProvider ?? '').trim(); const normalizedTwoFactorToken = String(twoFactorToken ?? '').trim(); const rememberRequested = ['1', 'true', 'True', 'TRUE', 'on', 'yes', 'Yes', 'YES'].includes(String(twoFactorRemember || '').trim()); @@ -164,7 +179,7 @@ export async function handleToken(request: Request, env: Env): Promise return twoFactorRequiredResponse(); } } else if (parsedProvider === TWO_FACTOR_PROVIDER_AUTHENTICATOR) { - const totpOk = await verifyTotpToken(env.TOTP_SECRET!, normalizedTwoFactorToken); + const totpOk = await verifyTotpToken(effectiveTotpSecret, normalizedTwoFactorToken); if (!totpOk) { return recordFailedTwoFactorAndBuildResponse(rateLimit, loginIdentifier); } diff --git a/src/handlers/setup.ts b/src/handlers/setup.ts index 5389ee3..517b1c1 100644 --- a/src/handlers/setup.ts +++ b/src/handlers/setup.ts @@ -31,6 +31,6 @@ export async function handleSetupPage(request: Request, env: Env): Promise { void request; const storage = new StorageService(env.DB); - const registered = await storage.isRegistered(); + const registered = (await storage.isRegistered()) || (await storage.getUserCount()) > 0; return jsonResponse({ registered }); } diff --git a/src/handlers/sync.ts b/src/handlers/sync.ts index 6619171..e5f74f1 100644 --- a/src/handlers/sync.ts +++ b/src/handlers/sync.ts @@ -74,7 +74,7 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr usesKeyConnector: false, masterPasswordHint: null, culture: 'en-US', - twoFactorEnabled: isTotpEnabled(env.TOTP_SECRET), + twoFactorEnabled: !!user.totpSecret || isTotpEnabled(env.TOTP_SECRET), key: user.key, privateKey: user.privateKey, accountKeys: null, diff --git a/src/handlers/web.ts b/src/handlers/web.ts new file mode 100644 index 0000000..229a3b0 --- /dev/null +++ b/src/handlers/web.ts @@ -0,0 +1,672 @@ +import { Env } from '../types'; +import { htmlResponse } from '../utils/response'; +import { LIMITS } from '../config/limits'; + +function renderWebClientHTML(): string { + const defaultKdfIterations = LIMITS.auth.defaultKdfIterations; + + return ` + + + + + NodeWarden Web + + + +
+ + +`; +} + +export async function handleWebClientPage(request: Request, env: Env): Promise { + void request; + void env; + return htmlResponse(renderWebClientHTML()); +} diff --git a/src/router.ts b/src/router.ts index 0b58c76..ddb24db 100644 --- a/src/router.ts +++ b/src/router.ts @@ -1,5 +1,6 @@ import { Env, DEFAULT_DEV_SECRET } from './types'; import { AuthService } from './services/auth'; +import { StorageService } from './services/storage'; import { RateLimitService, getClientIdentifier } from './services/ratelimit'; import { handleCors, errorResponse, jsonResponse } from './utils/response'; import { LIMITS } from './config/limits'; @@ -8,7 +9,17 @@ import { LIMITS } from './config/limits'; import { handleToken, handlePrelogin, handleRevocation } from './handlers/identity'; // Account handlers -import { handleRegister, handleGetProfile, handleUpdateProfile, handleSetKeys, handleGetRevisionDate, handleVerifyPassword } from './handlers/accounts'; +import { + handleRegister, + handleGetProfile, + handleUpdateProfile, + handleSetKeys, + handleGetRevisionDate, + handleVerifyPassword, + handleChangePassword, + handleGetTotpStatus, + handleSetTotpStatus, +} from './handlers/accounts'; // Cipher handlers import { @@ -38,6 +49,7 @@ import { handleSync } from './handlers/sync'; // Setup handlers import { handleSetupPage, handleSetupStatus } from './handlers/setup'; +import { handleWebClientPage } from './handlers/web'; import { handleKnownDevice, handleGetDevices, handleUpdateDeviceToken } from './handlers/devices'; // Import handler @@ -51,6 +63,14 @@ import { handleDeleteAttachment, handlePublicDownloadAttachment, } from './handlers/attachments'; +import { + handleAdminListUsers, + handleAdminCreateInvite, + handleAdminListInvites, + handleAdminRevokeInvite, + handleAdminSetUserStatus, + handleAdminDeleteUser, +} from './handlers/admin'; function isSameOriginWriteRequest(request: Request): boolean { const targetOrigin = new URL(request.url).origin; @@ -166,8 +186,13 @@ export async function handleRequest(request: Request, env: Env): Promise { + const admin = await this.db.prepare("SELECT id FROM users WHERE role = 'admin' LIMIT 1").first<{ id: string }>(); + if (admin?.id) return; + + const firstUser = await this.db + .prepare('SELECT id FROM users ORDER BY created_at ASC LIMIT 1') + .first<{ id: string }>(); + if (!firstUser?.id) return; + + await this.db + .prepare("UPDATE users SET role = 'admin', updated_at = ? WHERE id = ?") + .bind(new Date().toISOString(), firstUser.id) + .run(); + } + // --- Config / setup --- async isRegistered(): Promise { @@ -164,14 +196,7 @@ export class StorageService { // --- Users --- - async getUser(email: string): Promise { - const row = await this.db - .prepare( - 'SELECT id, email, name, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, created_at, updated_at FROM users WHERE email = ?' - ) - .bind(email.toLowerCase()) - .first(); - if (!row) return null; + private mapUserRow(row: any): User { return { id: row.id, email: row.email, @@ -185,45 +210,58 @@ export class StorageService { kdfMemory: row.kdf_memory ?? undefined, kdfParallelism: row.kdf_parallelism ?? undefined, securityStamp: row.security_stamp, + role: row.role === 'admin' ? 'admin' : 'user', + status: row.status === 'banned' ? 'banned' : 'active', + totpSecret: row.totp_secret ?? null, createdAt: row.created_at, updatedAt: row.updated_at, }; } + async getUser(email: string): Promise { + const row = await this.db + .prepare( + 'SELECT id, email, name, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, totp_secret, created_at, updated_at FROM users WHERE email = ?' + ) + .bind(email.toLowerCase()) + .first(); + if (!row) return null; + return this.mapUserRow(row); + } + async getUserById(id: string): Promise { const row = await this.db .prepare( - 'SELECT id, email, name, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, created_at, updated_at FROM users WHERE id = ?' + 'SELECT id, email, name, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, totp_secret, created_at, updated_at FROM users WHERE id = ?' ) .bind(id) .first(); if (!row) return null; - return { - id: row.id, - email: row.email, - name: row.name, - masterPasswordHash: row.master_password_hash, - key: row.key, - privateKey: row.private_key, - publicKey: row.public_key, - kdfType: row.kdf_type, - kdfIterations: row.kdf_iterations, - kdfMemory: row.kdf_memory ?? undefined, - kdfParallelism: row.kdf_parallelism ?? undefined, - securityStamp: row.security_stamp, - createdAt: row.created_at, - updatedAt: row.updated_at, - }; + return this.mapUserRow(row); + } + + async getUserCount(): Promise { + const row = await this.db.prepare('SELECT COUNT(*) AS count FROM users').first<{ count: number }>(); + return Number(row?.count || 0); + } + + async getAllUsers(): Promise { + const res = await this.db + .prepare( + 'SELECT id, email, name, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, totp_secret, created_at, updated_at FROM users ORDER BY created_at ASC' + ) + .all(); + return (res.results || []).map(row => this.mapUserRow(row)); } async saveUser(user: User): Promise { const email = user.email.toLowerCase(); const stmt = this.db.prepare( - 'INSERT INTO users(id, email, name, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, created_at, updated_at) ' + - 'VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' + + 'INSERT INTO users(id, email, name, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, totp_secret, created_at, updated_at) ' + + 'VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' + 'ON CONFLICT(id) DO UPDATE SET ' + 'email=excluded.email, name=excluded.name, master_password_hash=excluded.master_password_hash, key=excluded.key, private_key=excluded.private_key, public_key=excluded.public_key, ' + - 'kdf_type=excluded.kdf_type, kdf_iterations=excluded.kdf_iterations, kdf_memory=excluded.kdf_memory, kdf_parallelism=excluded.kdf_parallelism, security_stamp=excluded.security_stamp, updated_at=excluded.updated_at' + 'kdf_type=excluded.kdf_type, kdf_iterations=excluded.kdf_iterations, kdf_memory=excluded.kdf_memory, kdf_parallelism=excluded.kdf_parallelism, security_stamp=excluded.security_stamp, role=excluded.role, status=excluded.status, totp_secret=excluded.totp_secret, updated_at=excluded.updated_at' ); await this.safeBind(stmt, user.id, @@ -238,16 +276,23 @@ export class StorageService { user.kdfMemory, user.kdfParallelism, user.securityStamp, + user.role, + user.status, + user.totpSecret, user.createdAt, user.updatedAt ).run(); } + async createUser(user: User): Promise { + await this.saveUser(user); + } + async createFirstUser(user: User): Promise { const email = user.email.toLowerCase(); const stmt = this.db.prepare( - 'INSERT INTO users(id, email, name, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, created_at, updated_at) ' + - 'SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? ' + + 'INSERT INTO users(id, email, name, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, totp_secret, created_at, updated_at) ' + + 'SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? ' + 'WHERE NOT EXISTS (SELECT 1 FROM users LIMIT 1)' ); const result = await this.safeBind(stmt, @@ -263,6 +308,9 @@ export class StorageService { user.kdfMemory, user.kdfParallelism, user.securityStamp, + user.role, + user.status, + user.totpSecret, user.createdAt, user.updatedAt ).run(); @@ -270,6 +318,89 @@ export class StorageService { return (result.meta.changes ?? 0) > 0; } + async deleteUserById(id: string): Promise { + const result = await this.db.prepare('DELETE FROM users WHERE id = ?').bind(id).run(); + return (result.meta.changes ?? 0) > 0; + } + + async createInvite(invite: Invite): Promise { + await this.db + .prepare( + 'INSERT INTO invites(code, created_by, used_by, expires_at, status, created_at, updated_at) VALUES(?, ?, ?, ?, ?, ?, ?)' + ) + .bind(invite.code, invite.createdBy, invite.usedBy, invite.expiresAt, invite.status, invite.createdAt, invite.updatedAt) + .run(); + } + + async getInvite(code: string): Promise { + const row = await this.db + .prepare('SELECT code, created_by, used_by, expires_at, status, created_at, updated_at FROM invites WHERE code = ?') + .bind(code) + .first(); + if (!row) return null; + return { + code: row.code, + createdBy: row.created_by, + usedBy: row.used_by ?? null, + expiresAt: row.expires_at, + status: row.status, + createdAt: row.created_at, + updatedAt: row.updated_at, + }; + } + + async listInvites(includeInactive: boolean = false): Promise { + const now = new Date().toISOString(); + const predicate = includeInactive + ? '1 = 1' + : "(status = 'active' AND expires_at > ?)"; + const query = + 'SELECT code, created_by, used_by, expires_at, status, created_at, updated_at FROM invites ' + + `WHERE ${predicate} ORDER BY created_at DESC`; + const res = includeInactive + ? await this.db.prepare(query).all() + : await this.db.prepare(query).bind(now).all(); + + return (res.results || []).map(row => ({ + code: row.code, + createdBy: row.created_by, + usedBy: row.used_by ?? null, + expiresAt: row.expires_at, + status: row.status, + createdAt: row.created_at, + updatedAt: row.updated_at, + })); + } + + async markInviteUsed(code: string, userId: string): Promise { + const now = new Date().toISOString(); + const result = await this.db + .prepare( + "UPDATE invites SET status = 'used', used_by = ?, updated_at = ? WHERE code = ? AND status = 'active' AND expires_at > ?" + ) + .bind(userId, now, code, now) + .run(); + return (result.meta.changes ?? 0) > 0; + } + + async revokeInvite(code: string): Promise { + const now = new Date().toISOString(); + const result = await this.db + .prepare("UPDATE invites SET status = 'revoked', updated_at = ? WHERE code = ? AND status = 'active'") + .bind(now, code) + .run(); + return (result.meta.changes ?? 0) > 0; + } + + async createAuditLog(log: AuditLog): Promise { + await this.db + .prepare( + 'INSERT INTO audit_logs(id, actor_user_id, action, target_type, target_id, metadata, created_at) VALUES(?, ?, ?, ?, ?, ?, ?)' + ) + .bind(log.id, log.actorUserId, log.action, log.targetType, log.targetId, log.metadata, log.createdAt) + .run(); + } + // --- Ciphers --- async getCipher(id: string): Promise { @@ -632,6 +763,10 @@ export class StorageService { await this.db.prepare('DELETE FROM refresh_tokens WHERE token = ?').bind(tokenKey).run(); } + async deleteRefreshTokensByUserId(userId: string): Promise { + await this.db.prepare('DELETE FROM refresh_tokens WHERE user_id = ?').bind(userId).run(); + } + // Keep a short overlap window for rotated refresh token to reduce // multi-context refresh races (e.g. browser extension popup/background). // Expiry is only tightened, never extended. diff --git a/src/types/index.ts b/src/types/index.ts index cb7fed9..92cd0b8 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -6,6 +6,9 @@ export interface Env { TOTP_SECRET?: string; } +export type UserRole = 'admin' | 'user'; +export type UserStatus = 'active' | 'banned'; + // Sample JWT secret used by `.dev.vars.example`. // If runtime JWT_SECRET equals this value, treat it as unsafe. export const DEFAULT_DEV_SECRET = 'Enter-your-JWT-key-here-at-least-32-characters'; @@ -34,10 +37,33 @@ export interface User { kdfMemory?: number; kdfParallelism?: number; securityStamp: string; + role: UserRole; + status: UserStatus; + totpSecret: string | null; createdAt: string; updatedAt: string; } +export interface Invite { + code: string; + createdBy: string; + usedBy: string | null; + expiresAt: string; + status: 'active' | 'used' | 'revoked' | 'expired'; + createdAt: string; + updatedAt: string; +} + +export interface AuditLog { + id: string; + actorUserId: string | null; + action: string; + targetType: string | null; + targetId: string | null; + metadata: string | null; + createdAt: string; +} + // Cipher types export enum CipherType { Login = 1, @@ -235,6 +261,8 @@ export interface ProfileResponse { forcePasswordReset: boolean; avatarColor: string | null; creationDate: string; + role?: UserRole; + status?: UserStatus; object: string; }