mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-20 13:00:39 +00:00
911 lines
30 KiB
TypeScript
911 lines
30 KiB
TypeScript
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';
|
|
import { isTotpEnabled, verifyTotpToken } from '../utils/totp';
|
|
import { createRecoveryCode, recoveryCodeEquals } from '../utils/recovery-code';
|
|
import { buildAccountKeys } from '../utils/user-decryption';
|
|
|
|
// CONTRACT:
|
|
// users.master_password_hash is server-side login verification only. It does
|
|
// not decrypt vault data. Password changes must keep encrypted user key material,
|
|
// securityStamp, refresh-token invalidation, and client compatibility together.
|
|
// Password hints are non-secret reminders; never treat them as recovery secrets.
|
|
function looksLikeEncString(value: string): boolean {
|
|
if (!value) return false;
|
|
const firstDot = value.indexOf('.');
|
|
if (firstDot <= 0 || firstDot === value.length - 1) return false;
|
|
const payload = value.slice(firstDot + 1);
|
|
const parts = payload.split('|');
|
|
// Bitwarden encrypted payloads should have at least IV + ciphertext.
|
|
return parts.length >= 2;
|
|
}
|
|
|
|
/**
|
|
* Validate KDF parameters according to Bitwarden minimum requirements.
|
|
* Returns an error message if invalid, or null if OK.
|
|
*/
|
|
function validateKdfParams(kdfType: number | undefined, kdfIterations: number | undefined, kdfMemory?: number | undefined, kdfParallelism?: number | undefined): string | null {
|
|
const type = kdfType ?? 0;
|
|
if (type === 0) {
|
|
// PBKDF2-SHA256: minimum 100 000 iterations
|
|
if (typeof kdfIterations === 'number' && kdfIterations < 100_000) {
|
|
return 'PBKDF2 iterations must be at least 100000';
|
|
}
|
|
} else if (type === 1) {
|
|
// Argon2id: iterations >= 2, memory >= 16 MiB, parallelism >= 1
|
|
if (typeof kdfIterations === 'number' && kdfIterations < 2) {
|
|
return 'Argon2id iterations must be at least 2';
|
|
}
|
|
if (typeof kdfMemory === 'number' && kdfMemory < 16) {
|
|
return 'Argon2id memory must be at least 16 MiB';
|
|
}
|
|
if (typeof kdfParallelism === 'number' && kdfParallelism < 1) {
|
|
return 'Argon2id parallelism must be at least 1';
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function normalizeTotpSecret(input: string): string {
|
|
const raw = String(input || '').toUpperCase();
|
|
let out = '';
|
|
for (const char of raw) {
|
|
if (char === ' ' || char === '\t' || char === '\n' || char === '\r' || char === '-') continue;
|
|
out += char;
|
|
}
|
|
while (out.endsWith('=')) {
|
|
out = out.slice(0, -1);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function normalizeRecoveryCodeInput(input: string): string {
|
|
return String(input || '').toUpperCase().replace(/[^A-Z2-7]/g, '');
|
|
}
|
|
|
|
function normalizeMasterPasswordHint(input: string | null | undefined): string | null {
|
|
const normalized = String(input || '').trim();
|
|
return normalized ? normalized : null;
|
|
}
|
|
|
|
function jwtSecretUnsafeReason(env: Env): 'missing' | 'default' | 'too_short' | null {
|
|
const secret = (env.JWT_SECRET || '').trim();
|
|
if (!secret) return 'missing';
|
|
if (secret === DEFAULT_DEV_SECRET) return 'default';
|
|
if (secret.length < LIMITS.auth.jwtSecretMinLength) return 'too_short';
|
|
return null;
|
|
}
|
|
|
|
async function verifyUserSecret(
|
|
auth: AuthService,
|
|
user: User,
|
|
secret: string | null | undefined
|
|
): Promise<boolean> {
|
|
const normalized = String(secret || '').trim();
|
|
if (!normalized) return false;
|
|
return auth.verifyPassword(normalized, user.masterPasswordHash, user.email);
|
|
}
|
|
|
|
function toProfile(user: User, env: Env): ProfileResponse {
|
|
void env;
|
|
const accountKeys = buildAccountKeys(user);
|
|
return {
|
|
id: user.id,
|
|
name: user.name,
|
|
email: user.email,
|
|
emailVerified: true,
|
|
premium: true,
|
|
premiumFromOrganization: false,
|
|
usesKeyConnector: false,
|
|
masterPasswordHint: user.masterPasswordHint,
|
|
culture: 'en-US',
|
|
twoFactorEnabled: !!user.totpSecret,
|
|
key: user.key,
|
|
privateKey: user.privateKey,
|
|
accountKeys,
|
|
securityStamp: user.securityStamp || user.id,
|
|
organizations: [],
|
|
providers: [],
|
|
providerOrganizations: [],
|
|
forcePasswordReset: false,
|
|
avatarColor: null,
|
|
creationDate: user.createdAt,
|
|
verifyDevices: user.verifyDevices,
|
|
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<Response> {
|
|
const storage = new StorageService(env.DB);
|
|
|
|
const unsafe = jwtSecretUnsafeReason(env);
|
|
if (unsafe) {
|
|
const message = unsafe === 'missing'
|
|
? 'JWT_SECRET is not set'
|
|
: unsafe === 'default'
|
|
? 'JWT_SECRET is using the default/sample value. Please change it.'
|
|
: 'JWT_SECRET must be at least 32 characters';
|
|
return errorResponse(message, 400);
|
|
}
|
|
|
|
let body: {
|
|
email?: string;
|
|
name?: string;
|
|
masterPasswordHash?: string;
|
|
key?: string;
|
|
kdf?: number;
|
|
kdfIterations?: number;
|
|
kdfMemory?: number;
|
|
kdfParallelism?: number;
|
|
inviteCode?: string;
|
|
masterPasswordHint?: string;
|
|
keys?: {
|
|
publicKey?: string;
|
|
encryptedPrivateKey?: string;
|
|
};
|
|
};
|
|
|
|
try {
|
|
body = await request.json();
|
|
} catch {
|
|
return errorResponse('Invalid JSON', 400);
|
|
}
|
|
|
|
const email = body.email?.toLowerCase().trim();
|
|
const name = body.name?.trim() || email;
|
|
const masterPasswordHash = body.masterPasswordHash;
|
|
const key = body.key;
|
|
const privateKey = body.keys?.encryptedPrivateKey;
|
|
const publicKey = body.keys?.publicKey;
|
|
const inviteCode = (body.inviteCode || '').trim();
|
|
const masterPasswordHint = normalizeMasterPasswordHint(body.masterPasswordHint);
|
|
|
|
if (!email || !masterPasswordHash || !key) {
|
|
return errorResponse('Email, masterPasswordHash, and key are required', 400);
|
|
}
|
|
if (!email.includes('@') || email.length < 3) {
|
|
return errorResponse('Invalid email address', 400);
|
|
}
|
|
if (!privateKey || !publicKey) {
|
|
return errorResponse('Private key and public key are required', 400);
|
|
}
|
|
if (!looksLikeEncString(key)) {
|
|
return errorResponse('key is not a valid encrypted string', 400);
|
|
}
|
|
if (!looksLikeEncString(privateKey)) {
|
|
return errorResponse('encryptedPrivateKey is not a valid encrypted string', 400);
|
|
}
|
|
if (masterPasswordHint && masterPasswordHint.length > 120) {
|
|
return errorResponse('masterPasswordHint must be 120 characters or fewer', 400);
|
|
}
|
|
|
|
const kdfErr = validateKdfParams(body.kdf, body.kdfIterations, body.kdfMemory, body.kdfParallelism);
|
|
if (kdfErr) return errorResponse(kdfErr, 400);
|
|
|
|
const now = new Date().toISOString();
|
|
const auth = new AuthService(env);
|
|
const serverHash = await auth.hashPasswordServer(masterPasswordHash, email);
|
|
|
|
const user: User = {
|
|
id: generateUUID(),
|
|
email,
|
|
name: name || email,
|
|
masterPasswordHint,
|
|
masterPasswordHash: serverHash,
|
|
key,
|
|
privateKey,
|
|
publicKey,
|
|
kdfType: body.kdf ?? 0,
|
|
kdfIterations: body.kdfIterations ?? LIMITS.auth.defaultKdfIterations,
|
|
kdfMemory: body.kdfMemory,
|
|
kdfParallelism: body.kdfParallelism,
|
|
securityStamp: generateUUID(),
|
|
role: 'user',
|
|
status: 'active',
|
|
verifyDevices: true,
|
|
totpSecret: null,
|
|
totpRecoveryCode: null,
|
|
apiKey: null,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
};
|
|
|
|
const userCount = await storage.getUserCount();
|
|
if (userCount === 0) {
|
|
user.role = 'admin';
|
|
const created = await storage.createFirstUser(user);
|
|
if (!created) {
|
|
return errorResponse('Registration is temporarily unavailable, retry once', 409);
|
|
}
|
|
await storage.setRegistered();
|
|
await writeAuditEvent(storage, {
|
|
actorUserId: user.id,
|
|
action: 'user.register.first_admin',
|
|
targetType: 'user',
|
|
targetId: user.id,
|
|
category: 'security',
|
|
level: 'security',
|
|
metadata: { email: user.email, ...auditRequestMetadata(request) },
|
|
});
|
|
return jsonResponse({ success: true, role: user.role }, 200);
|
|
}
|
|
|
|
if (!inviteCode) {
|
|
return errorResponse('Invite code is required', 403);
|
|
}
|
|
|
|
try {
|
|
await storage.createUser(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;
|
|
}
|
|
|
|
const inviteMarked = await storage.markInviteUsed(inviteCode, user.id);
|
|
if (!inviteMarked) {
|
|
await storage.deleteUserById(user.id);
|
|
return errorResponse('Invite code is invalid or expired', 403);
|
|
}
|
|
|
|
await writeAuditEvent(storage, {
|
|
actorUserId: user.id,
|
|
action: 'user.register.invite',
|
|
targetType: 'user',
|
|
targetId: user.id,
|
|
category: 'security',
|
|
level: 'info',
|
|
metadata: { email: user.email, inviteCode, ...auditRequestMetadata(request) },
|
|
});
|
|
|
|
return jsonResponse({ success: true, role: user.role }, 200);
|
|
}
|
|
|
|
// POST /api/accounts/password-hint
|
|
export async function handleGetPasswordHint(request: Request, env: Env): Promise<Response> {
|
|
const storage = new StorageService(env.DB);
|
|
const clientIdentifier = getClientIdentifier(request);
|
|
if (!clientIdentifier) {
|
|
return errorResponse('Client IP is required', 403);
|
|
}
|
|
|
|
let body: { email?: string };
|
|
try {
|
|
body = await request.json();
|
|
} catch {
|
|
return errorResponse('Invalid JSON', 400);
|
|
}
|
|
|
|
const email = String(body.email || '').trim().toLowerCase();
|
|
if (!email) {
|
|
return errorResponse('Email is required', 400);
|
|
}
|
|
|
|
const rateLimit = new RateLimitService(env.DB);
|
|
const minuteBudget = await rateLimit.consumeBudgetWithWindow(
|
|
`${clientIdentifier}:password-hint`,
|
|
LIMITS.rateLimit.passwordHintRequestsPerMinute,
|
|
60
|
|
);
|
|
if (!minuteBudget.allowed) {
|
|
return new Response(
|
|
JSON.stringify({
|
|
error: 'Too many requests',
|
|
error_description: `Rate limit exceeded. Try again in ${minuteBudget.retryAfterSeconds || 60} seconds.`,
|
|
}),
|
|
{
|
|
status: 429,
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Retry-After': String(minuteBudget.retryAfterSeconds || 60),
|
|
'X-RateLimit-Remaining': '0',
|
|
},
|
|
}
|
|
);
|
|
}
|
|
|
|
const hourlyBudget = await rateLimit.consumeBudgetWithWindow(
|
|
`${clientIdentifier}:password-hint-hour`,
|
|
LIMITS.rateLimit.passwordHintRequestsPerHour,
|
|
60 * 60
|
|
);
|
|
if (!hourlyBudget.allowed) {
|
|
return new Response(
|
|
JSON.stringify({
|
|
error: 'Too many requests',
|
|
error_description: `Rate limit exceeded. Try again in ${hourlyBudget.retryAfterSeconds || 3600} seconds.`,
|
|
}),
|
|
{
|
|
status: 429,
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Retry-After': String(hourlyBudget.retryAfterSeconds || 3600),
|
|
'X-RateLimit-Remaining': '0',
|
|
},
|
|
}
|
|
);
|
|
}
|
|
|
|
const user = await storage.getUser(email);
|
|
const hint = user?.status === 'active' ? normalizeMasterPasswordHint(user.masterPasswordHint) : null;
|
|
return jsonResponse({
|
|
object: 'passwordHint',
|
|
hasHint: !!hint,
|
|
masterPasswordHint: hint,
|
|
});
|
|
}
|
|
|
|
// GET /api/accounts/profile
|
|
export async function handleGetProfile(request: Request, env: Env, userId: string): Promise<Response> {
|
|
void request;
|
|
const storage = new StorageService(env.DB);
|
|
const user = await storage.getUserById(userId);
|
|
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<Response> {
|
|
const storage = new StorageService(env.DB);
|
|
const user = await storage.getUserById(userId);
|
|
if (!user) return errorResponse('User not found', 404);
|
|
|
|
let body: {
|
|
masterPasswordHint?: string | null;
|
|
};
|
|
try {
|
|
body = await request.json();
|
|
} catch {
|
|
return errorResponse('Invalid JSON', 400);
|
|
}
|
|
|
|
const masterPasswordHint = normalizeMasterPasswordHint(body.masterPasswordHint);
|
|
if (masterPasswordHint && masterPasswordHint.length > 120) {
|
|
return errorResponse('masterPasswordHint must be 120 characters or fewer', 400);
|
|
}
|
|
|
|
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));
|
|
}
|
|
|
|
// PUT/POST /api/accounts/verify-devices
|
|
export async function handleSetVerifyDevices(request: Request, env: Env, userId: string): Promise<Response> {
|
|
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: {
|
|
secret?: string;
|
|
masterPasswordHash?: string;
|
|
verifyDevices?: boolean;
|
|
};
|
|
try {
|
|
body = await request.json();
|
|
} catch {
|
|
return errorResponse('Invalid JSON', 400);
|
|
}
|
|
|
|
if (typeof body.verifyDevices !== 'boolean') {
|
|
return errorResponse('verifyDevices must be true or false', 400);
|
|
}
|
|
|
|
const verified = await verifyUserSecret(auth, user, body.secret || body.masterPasswordHash);
|
|
if (!verified) {
|
|
return errorResponse('User verification failed.', 400);
|
|
}
|
|
|
|
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 });
|
|
}
|
|
|
|
// POST /api/accounts/keys
|
|
export async function handleSetKeys(request: Request, env: Env, userId: string): Promise<Response> {
|
|
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;
|
|
key?: string;
|
|
encryptedPrivateKey?: string;
|
|
publicKey?: string;
|
|
};
|
|
|
|
try {
|
|
body = await request.json();
|
|
} catch {
|
|
return errorResponse('Invalid JSON', 400);
|
|
}
|
|
|
|
// Require password verification before allowing key replacement.
|
|
if (!body.masterPasswordHash) {
|
|
return errorResponse('masterPasswordHash is required', 400);
|
|
}
|
|
const passwordValid = await auth.verifyPassword(body.masterPasswordHash, user.masterPasswordHash, user.email);
|
|
if (!passwordValid) {
|
|
return errorResponse('Invalid password', 400);
|
|
}
|
|
|
|
if (body.key && !looksLikeEncString(body.key)) {
|
|
return errorResponse('key is not a valid encrypted string', 400);
|
|
}
|
|
if (body.encryptedPrivateKey && !looksLikeEncString(body.encryptedPrivateKey)) {
|
|
return errorResponse('encryptedPrivateKey is not a valid encrypted string', 400);
|
|
}
|
|
|
|
if (body.key) user.key = body.key;
|
|
if (body.encryptedPrivateKey) user.privateKey = body.encryptedPrivateKey;
|
|
if (body.publicKey) user.publicKey = body.publicKey;
|
|
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);
|
|
}
|
|
|
|
// POST/PUT /api/accounts/password
|
|
export async function handleChangePassword(request: Request, env: Env, userId: string): Promise<Response> {
|
|
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, user.email);
|
|
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);
|
|
}
|
|
|
|
const kdfErr = validateKdfParams(body.kdf ?? user.kdfType, body.kdfIterations, body.kdfMemory, body.kdfParallelism);
|
|
if (kdfErr) return errorResponse(kdfErr, 400);
|
|
|
|
user.masterPasswordHash = await auth.hashPasswordServer(body.newMasterPasswordHash, user.email);
|
|
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);
|
|
AuthService.invalidateUserCache(user.id);
|
|
await writeAuditEvent(storage, {
|
|
actorUserId: user.id,
|
|
action: 'user.password.change',
|
|
targetType: 'user',
|
|
targetId: user.id,
|
|
category: 'security',
|
|
level: 'security',
|
|
metadata: { email: user.email, ...auditRequestMetadata(request) },
|
|
});
|
|
|
|
return new Response(null, { status: 200 });
|
|
}
|
|
|
|
// GET /api/accounts/totp
|
|
export async function handleGetTotpStatus(request: Request, env: Env, userId: string): Promise<Response> {
|
|
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<Response> {
|
|
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;
|
|
if (!user.totpRecoveryCode) {
|
|
user.totpRecoveryCode = createRecoveryCode();
|
|
}
|
|
user.updatedAt = new Date().toISOString();
|
|
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' });
|
|
}
|
|
|
|
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, user.email);
|
|
if (!valid) return errorResponse('Invalid password', 400);
|
|
|
|
user.totpSecret = null;
|
|
user.updatedAt = new Date().toISOString();
|
|
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' });
|
|
}
|
|
|
|
return errorResponse('enabled must be true or false', 400);
|
|
}
|
|
|
|
// POST /api/accounts/totp/recovery-code
|
|
export async function handleGetTotpRecoveryCode(request: Request, env: Env, userId: string): Promise<Response> {
|
|
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: Record<string, string | undefined>;
|
|
try {
|
|
const contentType = request.headers.get('content-type') || '';
|
|
if (contentType.includes('application/x-www-form-urlencoded')) {
|
|
const formData = await request.formData();
|
|
body = Object.fromEntries(formData.entries()) as Record<string, string>;
|
|
} else {
|
|
body = await request.json();
|
|
}
|
|
} catch {
|
|
return errorResponse('Invalid JSON', 400);
|
|
}
|
|
|
|
const currentHash = String(body.masterPasswordHash || body.master_password_hash || body.password || '').trim();
|
|
if (!currentHash) return errorResponse('masterPasswordHash is required', 400);
|
|
const valid = await auth.verifyPassword(currentHash, user.masterPasswordHash, user.email);
|
|
if (!valid) return errorResponse('Invalid password', 400);
|
|
|
|
if (!user.totpRecoveryCode) {
|
|
user.totpRecoveryCode = createRecoveryCode();
|
|
user.updatedAt = new Date().toISOString();
|
|
await storage.saveUser(user);
|
|
}
|
|
|
|
return jsonResponse({
|
|
code: user.totpRecoveryCode,
|
|
object: 'twoFactorRecover',
|
|
});
|
|
}
|
|
|
|
// POST /identity/accounts/recover-2fa
|
|
// Disable TOTP by recovery code + password, then rotate recovery code.
|
|
export async function handleRecoverTwoFactor(request: Request, env: Env): Promise<Response> {
|
|
const storage = new StorageService(env.DB);
|
|
const auth = new AuthService(env);
|
|
const rateLimit = new RateLimitService(env.DB);
|
|
|
|
let body: Record<string, string | undefined>;
|
|
try {
|
|
const contentType = request.headers.get('content-type') || '';
|
|
if (contentType.includes('application/x-www-form-urlencoded')) {
|
|
const formData = await request.formData();
|
|
body = Object.fromEntries(formData.entries()) as Record<string, string>;
|
|
} else {
|
|
body = await request.json();
|
|
}
|
|
} catch {
|
|
return errorResponse('Invalid JSON', 400);
|
|
}
|
|
|
|
const email = String(body.email || body.username || '').trim().toLowerCase();
|
|
const masterPasswordHash = String(body.masterPasswordHash || body.password || '').trim();
|
|
const recoveryCode = normalizeRecoveryCodeInput(String(body.recoveryCode || body.twoFactorToken || body.recovery_code || ''));
|
|
const clientIdentifier = getClientIdentifier(request);
|
|
if (!clientIdentifier) {
|
|
return errorResponse('Client IP is required', 403);
|
|
}
|
|
const recoverLimitKey = `${clientIdentifier}:recover-2fa`;
|
|
|
|
const recoverAttemptCheck = await rateLimit.checkLoginAttempt(recoverLimitKey);
|
|
if (!recoverAttemptCheck.allowed) {
|
|
return errorResponse(
|
|
`Too many failed recovery attempts. Try again in ${Math.ceil((recoverAttemptCheck.retryAfterSeconds || 60) / 60)} minutes.`,
|
|
429
|
|
);
|
|
}
|
|
|
|
if (!email || !masterPasswordHash || !recoveryCode) {
|
|
return errorResponse('Email, masterPasswordHash and recoveryCode are required', 400);
|
|
}
|
|
|
|
const user = await storage.getUser(email);
|
|
if (!user || user.status !== 'active') {
|
|
await rateLimit.recordFailedLogin(recoverLimitKey);
|
|
return errorResponse('Invalid credentials or recovery code', 400);
|
|
}
|
|
|
|
const validPassword = await auth.verifyPassword(masterPasswordHash, user.masterPasswordHash, user.email);
|
|
if (!validPassword) {
|
|
await rateLimit.recordFailedLogin(recoverLimitKey);
|
|
return errorResponse('Invalid credentials or recovery code', 400);
|
|
}
|
|
|
|
if (!recoveryCodeEquals(recoveryCode, user.totpRecoveryCode)) {
|
|
await rateLimit.recordFailedLogin(recoverLimitKey);
|
|
return errorResponse('Invalid credentials or recovery code', 400);
|
|
}
|
|
|
|
user.totpSecret = null;
|
|
user.totpRecoveryCode = createRecoveryCode();
|
|
user.securityStamp = generateUUID();
|
|
user.updatedAt = new Date().toISOString();
|
|
await storage.saveUser(user);
|
|
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,
|
|
twoFactorEnabled: false,
|
|
newRecoveryCode: user.totpRecoveryCode,
|
|
object: 'twoFactorRecovery',
|
|
});
|
|
}
|
|
|
|
// GET /api/accounts/revision-date
|
|
export async function handleGetRevisionDate(request: Request, env: Env, userId: string): Promise<Response> {
|
|
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);
|
|
}
|
|
|
|
// POST /api/accounts/verify-password
|
|
export async function handleVerifyPassword(request: Request, env: Env, userId: string): Promise<Response> {
|
|
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 };
|
|
try {
|
|
body = await request.json();
|
|
} catch {
|
|
return errorResponse('Invalid JSON', 400);
|
|
}
|
|
|
|
if (!body.masterPasswordHash) {
|
|
return errorResponse('masterPasswordHash is required', 400);
|
|
}
|
|
|
|
const valid = await auth.verifyPassword(body.masterPasswordHash, user.masterPasswordHash, user.email);
|
|
if (!valid) {
|
|
return errorResponse('Invalid password', 400);
|
|
}
|
|
|
|
return new Response(null, { status: 200 });
|
|
}
|
|
|
|
// POST /api/accounts/api-key
|
|
export async function handleGetApiKey(request: Request, env: Env, userId: string): Promise<Response> {
|
|
return apiKey(request, env, userId, false);
|
|
}
|
|
|
|
// POST /api/accounts/rotate-api-key
|
|
export async function handleRotateApiKey(request: Request, env: Env, userId: string): Promise<Response> {
|
|
return apiKey(request, env, userId, true);
|
|
}
|
|
|
|
async function apiKey(request: Request, env: Env, userId: string, rotate: boolean): Promise<Response> {
|
|
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: Record<string, string | undefined>;
|
|
try {
|
|
const contentType = request.headers.get('content-type') || '';
|
|
if (contentType.includes('application/x-www-form-urlencoded')) {
|
|
const formData = await request.formData();
|
|
body = Object.fromEntries(formData.entries()) as Record<string, string>;
|
|
} else {
|
|
body = await request.json();
|
|
}
|
|
} catch {
|
|
return errorResponse('Invalid JSON', 400);
|
|
}
|
|
|
|
const currentHash = String(body.masterPasswordHash || body.master_password_hash || body.password || '').trim();
|
|
if (!currentHash) return errorResponse('masterPasswordHash is required', 400);
|
|
const valid = await auth.verifyPassword(currentHash, user.masterPasswordHash, user.email);
|
|
if (!valid) return errorResponse('Invalid password', 400);
|
|
|
|
if (rotate || user.apiKey === null) {
|
|
// Upstream apikeys are 30-character random alphanumeric strings
|
|
user.apiKey = randomStringAlphanum(LIMITS.auth.clientSecretLength);
|
|
if (rotate) {
|
|
user.securityStamp = generateUUID();
|
|
await storage.deleteRefreshTokensByUserId(user.id);
|
|
}
|
|
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({
|
|
apiKey: user.apiKey,
|
|
revisionDate: user.updatedAt,
|
|
object: 'apiKey',
|
|
});
|
|
}
|
|
|
|
// Generate a random alphanumeric string of the given length using crypto.getRandomValues.
|
|
function randomStringAlphanum(length: number): string {
|
|
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
|
let result = '';
|
|
const maxUnbiased = Math.floor(256 / chars.length) * chars.length;
|
|
const bytes = new Uint8Array(Math.max(16, length));
|
|
|
|
while (result.length < length) {
|
|
crypto.getRandomValues(bytes);
|
|
for (const value of bytes) {
|
|
if (value >= maxUnbiased) continue;
|
|
result += chars[value % chars.length];
|
|
if (result.length >= length) break;
|
|
}
|
|
}
|
|
|
|
return result;
|
|
}
|