mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-20 13:00:39 +00:00
feat: implement admin user management and invite system
This commit is contained in:
@@ -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,
|
||||
|
||||
+249
-59
@@ -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<Response> {
|
||||
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<Respon
|
||||
email?: string;
|
||||
name?: string;
|
||||
masterPasswordHash?: string;
|
||||
masterPasswordHint?: string;
|
||||
key?: string;
|
||||
kdf?: number;
|
||||
kdfIterations?: number;
|
||||
kdfMemory?: number;
|
||||
kdfParallelism?: number;
|
||||
inviteCode?: string;
|
||||
keys?: {
|
||||
publicKey?: string;
|
||||
encryptedPrivateKey?: string;
|
||||
@@ -61,17 +94,17 @@ export async function handleRegister(request: Request, env: Env): Promise<Respon
|
||||
return errorResponse('Invalid JSON', 400);
|
||||
}
|
||||
|
||||
const email = body.email?.toLowerCase();
|
||||
const name = body.name || email;
|
||||
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();
|
||||
|
||||
if (!email || !masterPasswordHash || !key) {
|
||||
return errorResponse('Email, masterPasswordHash, and key are required', 400);
|
||||
}
|
||||
|
||||
if (!privateKey || !publicKey) {
|
||||
return errorResponse('Private key and public key are required', 400);
|
||||
}
|
||||
@@ -82,92 +115,121 @@ export async function handleRegister(request: Request, env: Env): Promise<Respon
|
||||
return errorResponse('encryptedPrivateKey is not a valid encrypted string', 400);
|
||||
}
|
||||
|
||||
// Create user
|
||||
const now = new Date().toISOString();
|
||||
const user: User = {
|
||||
id: generateUUID(),
|
||||
email: email,
|
||||
email,
|
||||
name: name || email,
|
||||
masterPasswordHash: masterPasswordHash,
|
||||
key: key,
|
||||
privateKey: privateKey,
|
||||
publicKey: publicKey,
|
||||
masterPasswordHash,
|
||||
key,
|
||||
privateKey,
|
||||
publicKey,
|
||||
kdfType: body.kdf ?? 0,
|
||||
kdfIterations: body.kdfIterations ?? LIMITS.auth.defaultKdfIterations,
|
||||
kdfMemory: body.kdfMemory,
|
||||
kdfParallelism: body.kdfParallelism,
|
||||
securityStamp: generateUUID(),
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
role: 'user',
|
||||
status: 'active',
|
||||
totpSecret: null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
const created = await storage.createFirstUser(user);
|
||||
if (!created) {
|
||||
return errorResponse('Registration is closed', 403);
|
||||
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 storage.createAuditLog({
|
||||
id: generateUUID(),
|
||||
actorUserId: user.id,
|
||||
action: 'user.register.first_admin',
|
||||
targetType: 'user',
|
||||
targetId: user.id,
|
||||
metadata: JSON.stringify({ email: user.email }),
|
||||
createdAt: now,
|
||||
});
|
||||
return jsonResponse({ success: true, role: user.role }, 200);
|
||||
}
|
||||
|
||||
await storage.setRegistered();
|
||||
if (!inviteCode) {
|
||||
return errorResponse('Invite code is required', 403);
|
||||
}
|
||||
|
||||
return jsonResponse({ success: true }, 200);
|
||||
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 storage.createAuditLog({
|
||||
id: generateUUID(),
|
||||
actorUserId: user.id,
|
||||
action: 'user.register.invite',
|
||||
targetType: 'user',
|
||||
targetId: user.id,
|
||||
metadata: JSON.stringify({ email: user.email, inviteCode }),
|
||||
createdAt: now,
|
||||
});
|
||||
|
||||
return jsonResponse({ success: true, role: user.role }, 200);
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
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<Response> {
|
||||
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<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);
|
||||
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<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;
|
||||
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<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);
|
||||
|
||||
@@ -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<string, unknown> | null
|
||||
): Promise<void> {
|
||||
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<string, unknown> {
|
||||
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<Response> {
|
||||
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<Response> {
|
||||
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<Response> {
|
||||
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<Response> {
|
||||
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<Response> {
|
||||
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<Response> {
|
||||
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 });
|
||||
}
|
||||
@@ -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<Response>
|
||||
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<Response>
|
||||
);
|
||||
}
|
||||
|
||||
// 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<Response>
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -31,6 +31,6 @@ export async function handleSetupPage(request: Request, env: Env): Promise<Respo
|
||||
export async function handleSetupStatus(request: Request, env: Env): Promise<Response> {
|
||||
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 });
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>NodeWarden Web</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #f4f0e7;
|
||||
--bg2: #e9f1ec;
|
||||
--panel: #fffdf8;
|
||||
--line: #d7ccbb;
|
||||
--text: #1f1710;
|
||||
--muted: #6a5f52;
|
||||
--primary: #a63c2b;
|
||||
--primary2: #1f6b5a;
|
||||
--danger: #a53024;
|
||||
--ok: #0f7a3d;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html, body { height: 100%; }
|
||||
body {
|
||||
margin: 0;
|
||||
color: var(--text);
|
||||
font-family: "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||
background:
|
||||
radial-gradient(circle at 12% 14%, #f6ddaf 0%, transparent 35%),
|
||||
radial-gradient(circle at 84% 20%, #d5efe4 0%, transparent 33%),
|
||||
linear-gradient(155deg, var(--bg) 0%, var(--bg2) 100%);
|
||||
}
|
||||
#app { min-height: 100%; }
|
||||
.shell { min-height: 100%; padding: 10px; }
|
||||
.auth {
|
||||
min-height: calc(100vh - 20px);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 18px;
|
||||
background: var(--panel);
|
||||
box-shadow: 0 20px 46px rgba(26, 18, 12, 0.12);
|
||||
display: grid;
|
||||
grid-template-columns: 360px 1fr;
|
||||
overflow: hidden;
|
||||
}
|
||||
.auth-left {
|
||||
border-right: 1px solid var(--line);
|
||||
padding: 24px;
|
||||
background: #fff8eb;
|
||||
}
|
||||
.brand {
|
||||
width: 58px;
|
||||
height: 58px;
|
||||
border-radius: 13px;
|
||||
background: #111;
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 800;
|
||||
margin-bottom: 14px;
|
||||
user-select: none;
|
||||
}
|
||||
.auth-left h1 { margin: 0; font-size: 30px; }
|
||||
.auth-left p { margin: 10px 0 0 0; color: var(--muted); line-height: 1.7; font-size: 14px; }
|
||||
.auth-right { padding: 24px; position: relative; }
|
||||
.section-title { margin: 0 0 12px 0; font-size: 28px; }
|
||||
.msg {
|
||||
margin-bottom: 12px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--line);
|
||||
padding: 10px 12px;
|
||||
font-size: 13px;
|
||||
background: #fff;
|
||||
}
|
||||
.msg.ok { color: var(--ok); border-color: #9dd2b6; background: #f0fbf4; }
|
||||
.msg.err { color: var(--danger); border-color: #f2b4a9; background: #fff5f2; }
|
||||
.row { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
|
||||
.field { margin-bottom: 10px; }
|
||||
.field label { display: block; margin-bottom: 6px; color: var(--muted); font-size: 13px; }
|
||||
.field input, .field select, .field textarea {
|
||||
width: 100%;
|
||||
min-height: 44px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
background: #fffdf8;
|
||||
color: var(--text);
|
||||
padding: 0 12px;
|
||||
font-size: 14px;
|
||||
}
|
||||
.field textarea {
|
||||
min-height: 90px;
|
||||
padding-top: 10px;
|
||||
padding-bottom: 10px;
|
||||
resize: vertical;
|
||||
}
|
||||
.actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 8px; }
|
||||
.btn {
|
||||
border: 1px solid #b8ad9c;
|
||||
background: #f4ede3;
|
||||
color: var(--text);
|
||||
border-radius: 10px;
|
||||
min-height: 40px;
|
||||
padding: 0 12px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn.primary { border-color: #8f3124; background: var(--primary); color: #fff; }
|
||||
.btn.secondary { border-color: #1b594c; background: var(--primary2); color: #fff; }
|
||||
.btn.danger { border-color: #7f261d; background: var(--danger); color: #fff; }
|
||||
.tiny { font-size: 12px; color: var(--muted); }
|
||||
.totp-mask {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(22, 17, 12, 0.48);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 16px;
|
||||
}
|
||||
.totp-box {
|
||||
width: min(460px, 100%);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 14px;
|
||||
background: #fff;
|
||||
padding: 16px;
|
||||
box-shadow: 0 18px 30px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
.totp-box h3 { margin: 0 0 8px 0; font-size: 20px; }
|
||||
.app-layout {
|
||||
min-height: calc(100vh - 20px);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 18px;
|
||||
background: var(--panel);
|
||||
box-shadow: 0 16px 40px rgba(26, 18, 12, 0.12);
|
||||
overflow: hidden;
|
||||
display: grid;
|
||||
}
|
||||
.app-layout.normal-layout { grid-template-columns: 250px 1fr; }
|
||||
.app-layout.vault-layout { grid-template-columns: 250px 260px 1fr; }
|
||||
.sidebar, .folderbar {
|
||||
border-right: 1px solid var(--line);
|
||||
padding: 14px;
|
||||
background: #fff8eb;
|
||||
min-width: 0;
|
||||
}
|
||||
.folderbar { background: #fffaf1; }
|
||||
.sidebar .brand { width: 50px; height: 50px; margin-bottom: 8px; }
|
||||
.sidebar .mail { font-size: 12px; color: var(--muted); margin-bottom: 10px; word-break: break-all; }
|
||||
.nav-btn, .folder-btn { width: 100%; text-align: left; margin-bottom: 8px; }
|
||||
.nav-btn.active { border-color: #8f3124; background: #fff2ea; color: #7f271c; }
|
||||
.folder-btn { margin-bottom: 6px; font-size: 13px; }
|
||||
.folder-btn.active { border-color: #1b594c; background: #e9f6f0; color: #184f43; }
|
||||
.content { padding: 12px; min-width: 0; overflow: auto; }
|
||||
.panel {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
background: #fff;
|
||||
padding: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.panel h3 { margin: 0 0 10px 0; font-size: 18px; }
|
||||
.vault-grid { display: grid; grid-template-columns: 1.1fr 1fr; gap: 12px; }
|
||||
.list {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
max-height: calc(100vh - 280px);
|
||||
overflow: auto;
|
||||
background: #fff;
|
||||
}
|
||||
.item {
|
||||
border-bottom: 1px solid var(--line);
|
||||
padding: 9px 10px;
|
||||
display: grid;
|
||||
grid-template-columns: 26px 1fr;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
.item:last-child { border-bottom: none; }
|
||||
.item.active { background: #fff2ea; }
|
||||
.kv {
|
||||
margin-bottom: 7px;
|
||||
font-size: 13px;
|
||||
line-height: 1.55;
|
||||
word-break: break-word;
|
||||
}
|
||||
.kv b { color: var(--muted); margin-right: 6px; }
|
||||
.table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
font-size: 13px;
|
||||
background: #fff;
|
||||
}
|
||||
.table th, .table td {
|
||||
border-bottom: 1px solid var(--line);
|
||||
padding: 8px;
|
||||
text-align: left;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.table tr:last-child td { border-bottom: none; }
|
||||
.qr-row { display: grid; grid-template-columns: 190px 1fr; gap: 12px; align-items: start; }
|
||||
.qr-box { border: 1px solid var(--line); border-radius: 10px; padding: 8px; background: #fff; }
|
||||
.qr-box img { width: 170px; height: 170px; display: block; object-fit: contain; background: #fff; }
|
||||
.help-box { border: 1px solid var(--line); border-radius: 10px; background: #fff; padding: 12px; margin-bottom: 10px; }
|
||||
.help-box h4 { margin: 0 0 8px 0; font-size: 15px; }
|
||||
.help-box ul { margin: 0; padding-left: 18px; line-height: 1.65; font-size: 13px; color: #3f352c; }
|
||||
@media (max-width: 1080px) {
|
||||
.auth { grid-template-columns: 1fr; }
|
||||
.auth-left { border-right: none; border-bottom: 1px solid var(--line); }
|
||||
.app-layout { grid-template-columns: 1fr; }
|
||||
.sidebar, .folderbar { border-right: none; border-bottom: 1px solid var(--line); }
|
||||
.vault-grid { grid-template-columns: 1fr; }
|
||||
.row { grid-template-columns: 1fr; }
|
||||
.qr-row { grid-template-columns: 1fr; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script>
|
||||
(function () {
|
||||
var app = document.getElementById('app');
|
||||
var defaultKdfIterations = ${defaultKdfIterations};
|
||||
var state = {
|
||||
phase: 'loading',
|
||||
msg: '',
|
||||
msgType: 'ok',
|
||||
inviteCode: '',
|
||||
session: null,
|
||||
profile: null,
|
||||
tab: 'vault',
|
||||
ciphers: [],
|
||||
folders: [],
|
||||
folderFilterId: '',
|
||||
selectedCipherId: '',
|
||||
selectedMap: {},
|
||||
users: [],
|
||||
invites: [],
|
||||
loginEmail: '',
|
||||
loginPassword: '',
|
||||
loginTotpToken: '',
|
||||
loginTotpError: '',
|
||||
pendingLogin: null,
|
||||
totpSetupSecret: '',
|
||||
totpSetupToken: '',
|
||||
totpDisableOpen: false,
|
||||
totpDisablePassword: '',
|
||||
totpDisableError: ''
|
||||
};
|
||||
var NO_FOLDER_FILTER = '__none__';
|
||||
|
||||
function esc(v) {
|
||||
return String(v == null ? '' : v).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
|
||||
}
|
||||
function sessionKey() { return 'nodewarden.web.session.v2'; }
|
||||
function setMsg(t, ty) { state.msg = t || ''; state.msgType = ty || 'ok'; render(); }
|
||||
function clearMsg() { state.msg = ''; }
|
||||
function renderMsg() { return state.msg ? '<div class="msg ' + (state.msgType === 'err' ? 'err' : 'ok') + '">' + esc(state.msg) + '</div>' : ''; }
|
||||
function saveSession() { if (state.session) localStorage.setItem(sessionKey(), JSON.stringify(state.session)); else localStorage.removeItem(sessionKey()); }
|
||||
function loadSession() { try { var r = localStorage.getItem(sessionKey()); if (!r) return null; var p = JSON.parse(r); if (!p || !p.accessToken || !p.refreshToken) return null; return p; } catch (e) { return null; } }
|
||||
function bytesToBase64(bytes) { var s=''; for (var i=0;i<bytes.length;i++) s += String.fromCharCode(bytes[i]); return btoa(s); }
|
||||
function concatBytes(a,b){ var o=new Uint8Array(a.length+b.length); o.set(a,0); o.set(b,a.length); return o; }
|
||||
async function pbkdf2(passwordOrBytes, saltOrBytes, iterations, keyLen){
|
||||
var enc=new TextEncoder();
|
||||
var pass=(passwordOrBytes instanceof Uint8Array)?passwordOrBytes:enc.encode(String(passwordOrBytes));
|
||||
var salt=(saltOrBytes instanceof Uint8Array)?saltOrBytes:enc.encode(String(saltOrBytes));
|
||||
var keyMaterial=await crypto.subtle.importKey('raw', pass, 'PBKDF2', false, ['deriveBits']);
|
||||
var bits=await crypto.subtle.deriveBits({name:'PBKDF2', salt:salt, iterations:iterations, hash:'SHA-256'}, keyMaterial, keyLen*8);
|
||||
return new Uint8Array(bits);
|
||||
}
|
||||
async function hkdfExpand(prk, info, length){
|
||||
var enc=new TextEncoder();
|
||||
var key=await crypto.subtle.importKey('raw', prk, {name:'HMAC', hash:'SHA-256'}, false, ['sign']);
|
||||
var infoBytes=enc.encode(info); var result=new Uint8Array(length); var prev=new Uint8Array(0); var off=0; var cnt=1;
|
||||
while(off<length){ var inp=new Uint8Array(prev.length+infoBytes.length+1); inp.set(prev,0); inp.set(infoBytes,prev.length); inp[inp.length-1]=cnt; var sig=new Uint8Array(await crypto.subtle.sign('HMAC', key, inp)); prev=sig; var c=Math.min(prev.length, length-off); result.set(prev.slice(0,c), off); off+=c; cnt++; }
|
||||
return result;
|
||||
}
|
||||
async function hmacSha256(keyBytes, dataBytes){ var key=await crypto.subtle.importKey('raw', keyBytes, {name:'HMAC', hash:'SHA-256'}, false, ['sign']); return new Uint8Array(await crypto.subtle.sign('HMAC', key, dataBytes)); }
|
||||
async function encryptAesCbc(data,key,iv){ var ck=await crypto.subtle.importKey('raw', key, {name:'AES-CBC'}, false, ['encrypt']); return new Uint8Array(await crypto.subtle.encrypt({name:'AES-CBC', iv:iv}, ck, data)); }
|
||||
async function encryptBw(data, encKey, macKey){ var iv=crypto.getRandomValues(new Uint8Array(16)); var cipher=await encryptAesCbc(data,encKey,iv); var mac=await hmacSha256(macKey, concatBytes(iv,cipher)); return '2.'+bytesToBase64(iv)+'|'+bytesToBase64(cipher)+'|'+bytesToBase64(mac); }
|
||||
async function jsonOrNull(resp){ var t=await resp.text(); if(!t) return null; try{ return JSON.parse(t);} catch(e){ return null; } }
|
||||
|
||||
function base64ToBytes(b64){ var bin=atob(b64); var bytes=new Uint8Array(bin.length); for(var i=0;i<bin.length;i++) bytes[i]=bin.charCodeAt(i); return bytes; }
|
||||
function parseCipherString(s){
|
||||
if(!s||typeof s!=='string') return null;
|
||||
var type,rest,dotIdx=s.indexOf('.');
|
||||
if(dotIdx>=0){ type=parseInt(s.substring(0,dotIdx),10); rest=s.substring(dotIdx+1); }
|
||||
else{ var pp=s.split('|'); type=(pp.length===3)?2:0; rest=s; }
|
||||
var parts=rest.split('|');
|
||||
if(type===2&&parts.length===3) return {type:2,iv:base64ToBytes(parts[0]),ct:base64ToBytes(parts[1]),mac:base64ToBytes(parts[2])};
|
||||
if((type===0||type===1||type===4)&&parts.length>=2) return {type:type,iv:base64ToBytes(parts[0]),ct:base64ToBytes(parts[1]),mac:null};
|
||||
return null;
|
||||
}
|
||||
async function decryptAesCbc(data,key,iv){ var ck=await crypto.subtle.importKey('raw',key,{name:'AES-CBC'},false,['decrypt']); return new Uint8Array(await crypto.subtle.decrypt({name:'AES-CBC',iv:iv},ck,data)); }
|
||||
async function decryptBw(cipherString,encKey,macKey){
|
||||
var parsed=parseCipherString(cipherString); if(!parsed) return null;
|
||||
if(parsed.type===2&&macKey&&parsed.mac){
|
||||
var macData=concatBytes(parsed.iv,parsed.ct); var computedMac=await hmacSha256(macKey,macData);
|
||||
var match=true; if(computedMac.length!==parsed.mac.length) match=false;
|
||||
else{ for(var i=0;i<computedMac.length;i++){if(computedMac[i]!==parsed.mac[i]){match=false;break;}} }
|
||||
if(!match) throw new Error('MAC mismatch');
|
||||
}
|
||||
return await decryptAesCbc(parsed.ct,encKey,parsed.iv);
|
||||
}
|
||||
async function decryptStr(cipherString,encKey,macKey){
|
||||
if(!cipherString) return '';
|
||||
try{ var bytes=await decryptBw(cipherString,encKey,macKey); if(!bytes) return String(cipherString); return new TextDecoder().decode(bytes); }
|
||||
catch(e){ return String(cipherString); }
|
||||
}
|
||||
async function decryptVault(){
|
||||
if(!state.session||!state.session.symEncKey||!state.session.symMacKey) return;
|
||||
var encKey=base64ToBytes(state.session.symEncKey); var macKey=base64ToBytes(state.session.symMacKey);
|
||||
for(var i=0;i<state.folders.length;i++){ state.folders[i].decName=await decryptStr(state.folders[i].name,encKey,macKey); }
|
||||
for(var i=0;i<state.ciphers.length;i++){
|
||||
var c=state.ciphers[i]; var ek=encKey,mk=macKey;
|
||||
if(c.key){ try{ var ikb=await decryptBw(c.key,encKey,macKey); if(ikb){ek=ikb.slice(0,32);mk=ikb.slice(32,64);} }catch(e){} }
|
||||
c.decName=await decryptStr(c.name,ek,mk); c.decNotes=await decryptStr(c.notes,ek,mk);
|
||||
if(c.login){
|
||||
c.login.decUsername=await decryptStr(c.login.username,ek,mk); c.login.decPassword=await decryptStr(c.login.password,ek,mk); c.login.decTotp=await decryptStr(c.login.totp,ek,mk);
|
||||
if(c.login.uris){for(var j=0;j<c.login.uris.length;j++){if(c.login.uris[j].uri) c.login.uris[j].decUri=await decryptStr(c.login.uris[j].uri,ek,mk);}}
|
||||
}
|
||||
if(c.card){
|
||||
c.card.decCardholderName=await decryptStr(c.card.cardholderName,ek,mk); c.card.decNumber=await decryptStr(c.card.number,ek,mk);
|
||||
c.card.decBrand=await decryptStr(c.card.brand,ek,mk); c.card.decExpMonth=await decryptStr(c.card.expMonth,ek,mk);
|
||||
c.card.decExpYear=await decryptStr(c.card.expYear,ek,mk); c.card.decCode=await decryptStr(c.card.code,ek,mk);
|
||||
}
|
||||
if(c.identity){
|
||||
c.identity.decFirstName=await decryptStr(c.identity.firstName,ek,mk); c.identity.decLastName=await decryptStr(c.identity.lastName,ek,mk);
|
||||
c.identity.decEmail=await decryptStr(c.identity.email,ek,mk); c.identity.decPhone=await decryptStr(c.identity.phone,ek,mk);
|
||||
c.identity.decCompany=await decryptStr(c.identity.company,ek,mk); c.identity.decUsername=await decryptStr(c.identity.username,ek,mk);
|
||||
}
|
||||
if(c.fields){ for(var j=0;j<c.fields.length;j++){ c.fields[j].decName=await decryptStr(c.fields[j].name,ek,mk); c.fields[j].decValue=await decryptStr(c.fields[j].value,ek,mk); } }
|
||||
}
|
||||
}
|
||||
|
||||
async function deriveLoginHash(email,password){
|
||||
var pre=await fetch('/identity/accounts/prelogin',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({email:email.toLowerCase()})});
|
||||
if(!pre.ok) throw new Error('prelogin failed');
|
||||
var d=await pre.json();
|
||||
var it=Number(d.kdfIterations||defaultKdfIterations);
|
||||
var mk=await pbkdf2(password,email.toLowerCase(),it,32);
|
||||
var h=await pbkdf2(mk,password,1,32);
|
||||
return { hash: bytesToBase64(h), masterKey: mk };
|
||||
}
|
||||
|
||||
function logout(){
|
||||
state.session=null; state.profile=null; state.ciphers=[]; state.folders=[]; state.users=[]; state.invites=[]; state.folderFilterId=''; state.selectedCipherId=''; state.selectedMap={}; state.pendingLogin=null; state.loginTotpToken=''; state.loginTotpError=''; state.totpDisableOpen=false; state.totpDisablePassword=''; state.totpDisableError=''; state.phase='login'; saveSession(); clearMsg(); render();
|
||||
}
|
||||
|
||||
async function authFetch(path, options){
|
||||
var opts=options||{}; if(!state.session||!state.session.accessToken) throw new Error('unauthorized');
|
||||
var h=opts.headers?Object.assign({},opts.headers):{}; h.Authorization='Bearer '+state.session.accessToken;
|
||||
var r=await fetch(path,Object.assign({},opts,{headers:h})); if(r.status!==401) return r; if(!state.session.refreshToken) return r;
|
||||
var f=new URLSearchParams(); f.set('grant_type','refresh_token'); f.set('refresh_token',state.session.refreshToken);
|
||||
var rr=await fetch('/identity/connect/token',{method:'POST',headers:{'Content-Type':'application/x-www-form-urlencoded'},body:f.toString()});
|
||||
if(!rr.ok){ logout(); return r; }
|
||||
var tj=await rr.json(); state.session.accessToken=tj.access_token; state.session.refreshToken=tj.refresh_token||state.session.refreshToken; saveSession();
|
||||
h.Authorization='Bearer '+state.session.accessToken; return fetch(path,Object.assign({},opts,{headers:h}));
|
||||
}
|
||||
|
||||
async function loadProfile(){ var r=await authFetch('/api/accounts/profile',{method:'GET'}); if(!r.ok) throw new Error('profile'); state.profile=await r.json(); }
|
||||
async function loadVault(){ var cr=await authFetch('/api/ciphers',{method:'GET'}); var fr=await authFetch('/api/folders',{method:'GET'}); if(!cr.ok||!fr.ok) throw new Error('vault'); var cj=await cr.json(); var fj=await fr.json(); state.ciphers=cj.data||[]; state.folders=fj.data||[]; if(!state.selectedCipherId&&state.ciphers.length>0) state.selectedCipherId=state.ciphers[0].id; await decryptVault(); }
|
||||
async function loadAdminData(){ if(!state.profile||state.profile.role!=='admin') return; var u=await authFetch('/api/admin/users',{method:'GET'}); if(u.ok){ var uj=await u.json(); state.users=uj.data||[]; } var i=await authFetch('/api/admin/invites?includeInactive=true',{method:'GET'}); if(i.ok){ var ij=await i.json(); state.invites=ij.data||[]; } }
|
||||
|
||||
function selectedCount(){ var n=0; for(var k in state.selectedMap){ if(state.selectedMap[k]) n++; } return n; }
|
||||
function filteredCiphers(){ var out=[]; for(var i=0;i<state.ciphers.length;i++){ var c=state.ciphers[i]; if(!state.folderFilterId) out.push(c); else if(state.folderFilterId===NO_FOLDER_FILTER&&(!c.folderId||c.folderId==='')) out.push(c); else if(c.folderId===state.folderFilterId) out.push(c);} return out; }
|
||||
function selectedCipher(){ if(!state.selectedCipherId) return null; var list=filteredCiphers(); for(var i=0;i<list.length;i++){ if(list[i].id===state.selectedCipherId) return list[i]; } return null; }
|
||||
function randomBase32Secret(len){ var a='ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; var b=crypto.getRandomValues(new Uint8Array(len)); var o=''; for(var i=0;i<b.length;i++) o+=a[b[i]%a.length]; return o; }
|
||||
function currentTotpSecret(){ if(!state.totpSetupSecret) state.totpSetupSecret=randomBase32Secret(32); return state.totpSetupSecret; }
|
||||
function buildTotpUri(secret){ var issuer='NodeWarden'; var account=state.profile&&state.profile.email?state.profile.email:'account'; return 'otpauth://totp/'+encodeURIComponent(issuer+':'+account)+'?secret='+encodeURIComponent(secret)+'&issuer='+encodeURIComponent(issuer)+'&algorithm=SHA1&digits=6&period=30'; }
|
||||
function renderLoginScreen(){
|
||||
return ''
|
||||
+ '<div class="shell"><div class="auth">'
|
||||
+ ' <aside class="auth-left"><div class="brand">NW</div><h1>NodeWarden Web</h1><p>Password errors keep email/password fields. If 2FA is enabled, password step is done once and TOTP is entered in modal only.</p></aside>'
|
||||
+ ' <main class="auth-right"><h2 class="section-title">Sign In</h2>'
|
||||
+ renderMsg()
|
||||
+ ' <form id="loginForm">'
|
||||
+ ' <div class="field"><label>Email</label><input type="email" name="email" value="'+esc(state.loginEmail)+'" required /></div>'
|
||||
+ ' <div class="field"><label>Master Password</label><input type="password" name="password" value="'+esc(state.loginPassword)+'" required /></div>'
|
||||
+ ' <div class="actions"><button class="btn primary" type="submit">Login</button><button class="btn" type="button" data-action="goto-register">Register</button></div>'
|
||||
+ ' </form>'
|
||||
+ (state.pendingLogin ? ''
|
||||
+ '<div class="totp-mask"><div class="totp-box"><h3>Two-step verification</h3><div class="tiny">Password is already verified.</div>'
|
||||
+ (state.loginTotpError?'<div class="msg err" style="margin-top:8px;">'+esc(state.loginTotpError)+'</div>':'')
|
||||
+ '<form id="loginTotpForm"><div class="field"><label>TOTP Code</label><input name="totpToken" maxlength="6" value="'+esc(state.loginTotpToken)+'" required /></div><div class="actions"><button class="btn primary" type="submit">Verify</button><button class="btn" type="button" data-action="totp-cancel">Cancel</button></div></form>'
|
||||
+ '</div></div>'
|
||||
: '')
|
||||
+ ' </main>'
|
||||
+ '</div></div>';
|
||||
}
|
||||
|
||||
function renderRegisterScreen(){
|
||||
return ''
|
||||
+ '<div class="shell"><div class="auth">'
|
||||
+ ' <aside class="auth-left"><div class="brand">NW</div><h1>NodeWarden Web</h1><p>First account becomes admin. Later accounts require invite code.</p></aside>'
|
||||
+ ' <main class="auth-right"><h2 class="section-title">Register</h2>'
|
||||
+ renderMsg()
|
||||
+ ' <form id="registerForm">'
|
||||
+ ' <div class="row"><div class="field"><label>Name</label><input name="name" required /></div><div class="field"><label>Email</label><input type="email" name="email" required /></div></div>'
|
||||
+ ' <div class="field"><label>Master Password</label><input type="password" name="password" minlength="12" required /></div>'
|
||||
+ ' <div class="field"><label>Confirm Password</label><input type="password" name="password2" minlength="12" required /></div>'
|
||||
+ ' <div class="field"><label>Invite Code</label><input name="inviteCode" value="'+esc(state.inviteCode)+'" /></div>'
|
||||
+ ' <div class="actions"><button class="btn primary" type="submit">Create Account</button><button class="btn" type="button" data-action="goto-login">Back to Login</button></div>'
|
||||
+ ' </form>'
|
||||
+ ' </main>'
|
||||
+ '</div></div>';
|
||||
}
|
||||
|
||||
function renderVaultTab(){
|
||||
var list=filteredCiphers();
|
||||
var rows='';
|
||||
for(var i=0;i<list.length;i++){
|
||||
var c=list[i];
|
||||
var nameText=(c.decName||c.name||c.id);
|
||||
rows += '<div class="item '+(c.id===state.selectedCipherId?'active':'')+'" data-action="pick-cipher" data-id="'+esc(c.id)+'"><input type="checkbox" data-action="toggle-select" data-id="'+esc(c.id)+'"'+(state.selectedMap[c.id]?' checked':'')+' /><div><div style="font-weight:700;font-size:14px;">'+esc(nameText)+'</div><div class="tiny">'+esc(c.id)+'</div></div></div>';
|
||||
}
|
||||
if(!rows) rows='<div class="item"><div></div><div class="tiny">No items in this folder.</div></div>';
|
||||
|
||||
var c0=selectedCipher();
|
||||
var detail='<div class="tiny">Select an item to view details.</div>';
|
||||
if(c0){
|
||||
var login = c0.login||{};
|
||||
var fields=Array.isArray(c0.fields)?c0.fields:[];
|
||||
var fh='';
|
||||
for(var j=0;j<fields.length;j++) fh += '<div class="kv"><b>'+(esc(fields[j].decName||fields[j].name||'Field '+(j+1)))+':</b> '+esc(fields[j].decValue||fields[j].value||'')+'</div>';
|
||||
var uriHtml=''; if(login.uris){for(var j=0;j<login.uris.length;j++){var u=login.uris[j]; uriHtml+='<div class="kv"><b>URI '+(j+1)+':</b> '+esc(u.decUri||u.uri||'')+'</div>';}}
|
||||
var cardHtml=''; if(c0.card){var cd=c0.card; cardHtml='<div class="kv"><b>Cardholder:</b> '+esc(cd.decCardholderName||cd.cardholderName||'')+'</div><div class="kv"><b>Number:</b> '+esc(cd.decNumber||cd.number||'')+'</div><div class="kv"><b>Brand:</b> '+esc(cd.decBrand||cd.brand||'')+'</div><div class="kv"><b>Exp:</b> '+esc(cd.decExpMonth||cd.expMonth||'')+'/'+esc(cd.decExpYear||cd.expYear||'')+'</div><div class="kv"><b>CVV:</b> '+esc(cd.decCode||cd.code||'')+'</div>';}
|
||||
var identHtml=''; if(c0.identity){var id=c0.identity; identHtml='<div class="kv"><b>Name:</b> '+esc((id.decFirstName||id.firstName||'')+' '+(id.decLastName||id.lastName||''))+'</div><div class="kv"><b>Email:</b> '+esc(id.decEmail||id.email||'')+'</div><div class="kv"><b>Phone:</b> '+esc(id.decPhone||id.phone||'')+'</div><div class="kv"><b>Company:</b> '+esc(id.decCompany||id.company||'')+'</div><div class="kv"><b>Username:</b> '+esc(id.decUsername||id.username||'')+'</div>';}
|
||||
detail=''
|
||||
+ '<div class="kv"><b>Name:</b> '+esc(c0.decName||c0.name||'')+'</div>'
|
||||
+ '<div class="kv"><b>Notes:</b> '+esc(c0.decNotes||c0.notes||'')+'</div>'
|
||||
+ (c0.login?('<div class="kv"><b>Username:</b> '+esc(login.decUsername||login.username||'')+'</div>'
|
||||
+ '<div class="kv"><b>Password:</b> '+esc(login.decPassword||login.password||'')+'</div>'
|
||||
+ '<div class="kv"><b>TOTP:</b> '+esc(login.decTotp||login.totp||'')+'</div>'+uriHtml):''
|
||||
) + cardHtml + identHtml + fh;
|
||||
}
|
||||
|
||||
return ''
|
||||
+ renderMsg()
|
||||
+ '<div class="panel"><h3>Vault</h3>'
|
||||
+ '<div class="actions"><button class="btn" data-action="vault-refresh">Refresh</button><button class="btn" data-action="bulk-move">Move Selected</button><button class="btn danger" data-action="bulk-delete">Delete Selected ('+selectedCount()+')</button><button class="btn" data-action="select-all">Select all</button><button class="btn" data-action="select-none">Clear</button></div>'
|
||||
+ '<div class="vault-grid" style="margin-top:10px;"><div class="list">'+rows+'</div><div class="panel" style="margin:0;">'+detail+'</div></div>'
|
||||
+ '</div>';
|
||||
}
|
||||
|
||||
function renderSettingsTab(){
|
||||
var p=state.profile||{};
|
||||
var secret=currentTotpSecret();
|
||||
var qr='https://api.qrserver.com/v1/create-qr-code/?size=180x180&data='+encodeURIComponent(buildTotpUri(secret));
|
||||
return ''
|
||||
+ renderMsg()
|
||||
+ '<div class="panel"><h3>Profile</h3><form id="profileForm"><div class="row"><div class="field"><label>Name</label><input name="name" value="'+esc(p.name||'')+'" /></div><div class="field"><label>Email</label><input type="email" name="email" value="'+esc(p.email||'')+'" required /></div></div><div class="actions"><button class="btn primary" type="submit">Save Profile</button></div></form></div>'
|
||||
+ '<div class="panel"><h3>TOTP Setup</h3><div class="qr-row"><div class="qr-box"><img src="'+esc(qr)+'" alt="TOTP QR" /></div><div><form id="totpEnableForm"><div class="field"><label>Secret (Base32)</label><input name="secret" value="'+esc(secret)+'" /></div><div class="field"><label>Verification Code</label><input name="token" maxlength="6" value="'+esc(state.totpSetupToken)+'" /></div><div class="actions"><button class="btn secondary" type="submit">Enable TOTP</button><button class="btn" type="button" data-action="totp-secret-refresh">Regenerate</button><button class="btn" type="button" data-action="totp-secret-copy">Copy Secret</button></div></form></div></div><div class="actions"><button class="btn danger" type="button" data-action="totp-disable">Disable TOTP</button></div><div class="tiny">Disable action prompts for master password.</div></div>';
|
||||
}
|
||||
function renderTotpDisableModal(){
|
||||
if(!state.totpDisableOpen) return '';
|
||||
return ''
|
||||
+ '<div class="totp-mask"><div class="totp-box"><h3>Disable TOTP</h3><div class="tiny">Enter master password to disable two-step verification.</div>'
|
||||
+ (state.totpDisableError?'<div class="msg err" style="margin-top:8px;">'+esc(state.totpDisableError)+'</div>':'')
|
||||
+ '<form id="totpDisableForm"><div class="field"><label>Master Password</label><input type="password" name="masterPassword" value="'+esc(state.totpDisablePassword)+'" required /></div><div class="actions"><button class="btn danger" type="submit">Disable</button><button class="btn" type="button" data-action="totp-disable-cancel">Cancel</button></div></form>'
|
||||
+ '</div></div>';
|
||||
}
|
||||
|
||||
function renderHelpTab(){
|
||||
return ''
|
||||
+ '<div class="help-box"><h4>Upstream Sync</h4><ul><li>Use fork + GitHub Actions scheduled sync.</li><li>Or use manual Sync fork from repository page.</li><li>Deploy updated branch in Cloudflare Worker after sync.</li></ul></div>'
|
||||
+ '<div class="help-box"><h4>Common Errors</h4><ul><li>401 Unauthorized: login again.</li><li>429 Too many requests: wait and retry.</li><li>403 Invite invalid: check invite status and expiry.</li><li>Disabled user cannot login.</li></ul></div>';
|
||||
}
|
||||
|
||||
function renderAdminTab(){
|
||||
var usersRows='';
|
||||
for(var i=0;i<state.users.length;i++){
|
||||
var u=state.users[i]; var canAct=state.profile&&u.id!==state.profile.id;
|
||||
usersRows += '<tr><td>'+esc(u.email)+'</td><td>'+esc(u.name||'')+'</td><td>'+esc(u.role)+'</td><td>'+esc(u.status)+'</td><td>'
|
||||
+ (canAct?'<button class="btn" data-action="user-toggle" data-id="'+esc(u.id)+'" data-status="'+esc(u.status)+'">'+(u.status==='active'?'Ban':'Unban')+'</button>':'')
|
||||
+ (canAct?' <button class="btn danger" data-action="user-delete" data-id="'+esc(u.id)+'">Delete</button>':'')
|
||||
+ '</td></tr>';
|
||||
}
|
||||
if(!usersRows) usersRows='<tr><td colspan="5">No users.</td></tr>';
|
||||
|
||||
var inviteRows='';
|
||||
for(var j=0;j<state.invites.length;j++){
|
||||
var inv=state.invites[j];
|
||||
inviteRows += '<tr><td>'+esc(inv.code)+'</td><td>'+esc(inv.status)+'</td><td>'+esc(inv.expiresAt)+'</td><td>'
|
||||
+ '<button class="btn" data-action="invite-copy" data-link="'+esc(inv.inviteLink||'')+'">Copy link</button>'
|
||||
+ (inv.status==='active'?' <button class="btn danger" data-action="invite-revoke" data-code="'+esc(inv.code)+'">Revoke</button>':'')
|
||||
+ '</td></tr>';
|
||||
}
|
||||
if(!inviteRows) inviteRows='<tr><td colspan="4">No invites.</td></tr>';
|
||||
|
||||
return ''
|
||||
+ renderMsg()
|
||||
+ '<div class="panel"><h3>Create Invite</h3><form id="inviteForm"><div class="field"><label>Expires in hours</label><input name="hours" type="number" min="1" max="720" value="168" /></div><div class="actions"><button class="btn primary" type="submit">Create Invite</button><button class="btn" type="button" data-action="admin-refresh">Refresh</button></div></form></div>'
|
||||
+ '<div class="panel"><h3>Users</h3><table class="table"><thead><tr><th>Email</th><th>Name</th><th>Role</th><th>Status</th><th>Action</th></tr></thead><tbody>'+usersRows+'</tbody></table></div>'
|
||||
+ '<div class="panel"><h3>Invites</h3><table class="table"><thead><tr><th>Code</th><th>Status</th><th>Expires At</th><th>Action</th></tr></thead><tbody>'+inviteRows+'</tbody></table></div>';
|
||||
}
|
||||
|
||||
function renderApp(){
|
||||
var isAdmin=state.profile&&state.profile.role==='admin';
|
||||
var showFolders=state.tab==='vault';
|
||||
var folders='<button class="btn folder-btn '+(!state.folderFilterId?'active':'')+'" data-action="folder-filter" data-folder="">All items</button>'
|
||||
+ '<button class="btn folder-btn '+(state.folderFilterId===NO_FOLDER_FILTER?'active':'')+'" data-action="folder-filter" data-folder="'+NO_FOLDER_FILTER+'">无文件夹</button>';
|
||||
for(var i=0;i<state.folders.length;i++){ var f=state.folders[i]; var folderName=(f.decName||f.name||f.id); folders += '<button class="btn folder-btn '+(state.folderFilterId===f.id?'active':'')+' " data-action="folder-filter" data-folder="'+esc(f.id)+'">'+esc(folderName)+'</button>'; }
|
||||
var content = state.tab==='vault'?renderVaultTab():state.tab==='settings'?renderSettingsTab():(state.tab==='admin'&&isAdmin)?renderAdminTab():renderHelpTab();
|
||||
var layoutClass=showFolders?'vault-layout':'normal-layout';
|
||||
return ''
|
||||
+ '<div class="shell"><div class="app-layout '+layoutClass+'">'
|
||||
+ ' <aside class="sidebar"><div class="brand">NW</div><div class="mail">'+esc(state.profile&&state.profile.email?state.profile.email:'')+'</div>'
|
||||
+ ' <button class="btn nav-btn '+(state.tab==='vault'?'active':'')+'" data-action="tab" data-tab="vault">Vault</button>'
|
||||
+ ' <button class="btn nav-btn '+(state.tab==='settings'?'active':'')+'" data-action="tab" data-tab="settings">Settings</button>'
|
||||
+ (isAdmin?'<button class="btn nav-btn '+(state.tab==='admin'?'active':'')+'" data-action="tab" data-tab="admin">User Management</button>':'')
|
||||
+ ' <button class="btn nav-btn '+(state.tab==='help'?'active':'')+'" data-action="tab" data-tab="help">Help</button>'
|
||||
+ ' <button class="btn nav-btn" data-action="logout">Logout</button></aside>'
|
||||
+ (showFolders?(' <aside class="folderbar"><h3 style="margin:0 0 10px 0;">Folders</h3>'+folders+'</aside>'):'')
|
||||
+ ' <main class="content">'+content+'</main>'
|
||||
+ '</div>'+renderTotpDisableModal()+'</div>';
|
||||
}
|
||||
|
||||
function render(){
|
||||
if(state.phase==='loading'){ app.innerHTML='<div class="shell"><div class="panel"><h3>Loading...</h3></div></div>'; return; }
|
||||
if(state.phase==='register'){ app.innerHTML=renderRegisterScreen(); return; }
|
||||
if(state.phase==='login'){ app.innerHTML=renderLoginScreen(); return; }
|
||||
app.innerHTML=renderApp();
|
||||
}
|
||||
|
||||
async function init(){
|
||||
var url=new URL(window.location.href); state.inviteCode=(url.searchParams.get('invite')||'').trim(); state.session=loadSession();
|
||||
var st=await fetch('/setup/status'); var setup=await jsonOrNull(st); var registered=!!(setup&&setup.registered);
|
||||
if(state.session){
|
||||
try{ await loadProfile(); await loadVault(); await loadAdminData(); state.phase='app'; state.tab='vault'; render(); return; } catch(e){ state.session=null; saveSession(); }
|
||||
}
|
||||
state.phase=registered?'login':'register'; render();
|
||||
}
|
||||
|
||||
async function onRegister(form){
|
||||
clearMsg();
|
||||
var fd=new FormData(form); var name=String(fd.get('name')||'').trim(); var email=String(fd.get('email')||'').trim().toLowerCase(); var p=String(fd.get('password')||''); var p2=String(fd.get('password2')||''); var invite=String(fd.get('inviteCode')||'').trim();
|
||||
if(!email||!p) return setMsg('Please input email and password.', 'err');
|
||||
if(p.length<12) return setMsg('Master password must be at least 12 chars.', 'err');
|
||||
if(p!==p2) return setMsg('Passwords do not match.', 'err');
|
||||
try{
|
||||
var it=defaultKdfIterations; var mk=await pbkdf2(p,email,it,32); var hash=await pbkdf2(mk,p,1,32); var ek=await hkdfExpand(mk,'enc',32); var em=await hkdfExpand(mk,'mac',32); var sym=crypto.getRandomValues(new Uint8Array(64)); var encKey=await encryptBw(sym,ek,em);
|
||||
var kp=await crypto.subtle.generateKey({name:'RSA-OAEP', modulusLength:2048, publicExponent:new Uint8Array([1,0,1]), hash:'SHA-1'}, true, ['encrypt','decrypt']);
|
||||
var pub=new Uint8Array(await crypto.subtle.exportKey('spki',kp.publicKey)); var prv=new Uint8Array(await crypto.subtle.exportKey('pkcs8',kp.privateKey)); var encPrv=await encryptBw(prv,sym.slice(0,32),sym.slice(32,64));
|
||||
var resp=await fetch('/api/accounts/register',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({email:email,name:name,masterPasswordHash:bytesToBase64(hash),key:encKey,kdf:0,kdfIterations:it,inviteCode:invite||undefined,keys:{publicKey:bytesToBase64(pub),encryptedPrivateKey:encPrv}})});
|
||||
var j=await jsonOrNull(resp); if(!resp.ok) return setMsg((j&&(j.error||j.error_description))||'Register failed.', 'err');
|
||||
state.phase='login'; state.loginEmail=email; state.loginPassword=''; setMsg('Registration succeeded. Please sign in.', 'ok');
|
||||
}catch(e){ setMsg(e&&e.message?e.message:String(e), 'err'); }
|
||||
}
|
||||
|
||||
async function onLoginPassword(form){
|
||||
clearMsg();
|
||||
var fd=new FormData(form); state.loginEmail=String(fd.get('email')||'').trim().toLowerCase(); state.loginPassword=String(fd.get('password')||'');
|
||||
if(!state.loginEmail||!state.loginPassword) return setMsg('Please input email and password.', 'err');
|
||||
try{
|
||||
var d=await deriveLoginHash(state.loginEmail,state.loginPassword);
|
||||
var body=new URLSearchParams(); body.set('grant_type','password'); body.set('username',state.loginEmail); body.set('password',d.hash); body.set('scope','api offline_access');
|
||||
var resp=await fetch('/identity/connect/token',{method:'POST',headers:{'Content-Type':'application/x-www-form-urlencoded'},body:body.toString()});
|
||||
var j=await jsonOrNull(resp);
|
||||
if(!resp.ok){
|
||||
if(j&&j.TwoFactorProviders){ state.pendingLogin={email:state.loginEmail,passwordHash:d.hash,masterKey:d.masterKey}; state.loginTotpToken=''; state.loginTotpError=''; clearMsg(); render(); return; }
|
||||
return setMsg((j&&(j.error_description||j.error))||'Login failed.', 'err');
|
||||
}
|
||||
await onLoginSuccess(j,d.masterKey,state.loginEmail,state.loginPassword);
|
||||
}catch(e){ setMsg(e&&e.message?e.message:String(e), 'err'); }
|
||||
}
|
||||
|
||||
async function onLoginTotp(form){
|
||||
if(!state.pendingLogin) return setMsg('TOTP flow is not ready.', 'err');
|
||||
var fd=new FormData(form); state.loginTotpToken=String(fd.get('totpToken')||'').trim(); if(!state.loginTotpToken){ state.loginTotpError='Please input TOTP code.'; render(); return; }
|
||||
var b=new URLSearchParams(); b.set('grant_type','password'); b.set('username',state.pendingLogin.email); b.set('password',state.pendingLogin.passwordHash); b.set('scope','api offline_access'); b.set('twoFactorProvider','0'); b.set('twoFactorToken',state.loginTotpToken);
|
||||
var resp=await fetch('/identity/connect/token',{method:'POST',headers:{'Content-Type':'application/x-www-form-urlencoded'},body:b.toString()});
|
||||
var j=await jsonOrNull(resp); if(!resp.ok){ state.loginTotpError=(j&&(j.error_description||j.error))||'TOTP verification failed.'; render(); return; }
|
||||
state.loginTotpError='';
|
||||
await onLoginSuccess(j,state.pendingLogin.masterKey,state.pendingLogin.email,state.loginPassword);
|
||||
}
|
||||
|
||||
async function onLoginSuccess(tokenJson, masterKey, email, password){
|
||||
state.session={accessToken:tokenJson.access_token,refreshToken:tokenJson.refresh_token,email:email}; saveSession(); state.pendingLogin=null; state.loginTotpToken=''; state.loginTotpError='';
|
||||
await loadProfile();
|
||||
try{
|
||||
var ek=await hkdfExpand(masterKey,'enc',32); var em=await hkdfExpand(masterKey,'mac',32);
|
||||
var symKeyBytes=await decryptBw(state.profile.key,ek,em);
|
||||
if(symKeyBytes){ state.session.symEncKey=bytesToBase64(symKeyBytes.slice(0,32)); state.session.symMacKey=bytesToBase64(symKeyBytes.slice(32,64)); saveSession(); }
|
||||
}catch(e){ console.warn('Key derivation failed:',e); }
|
||||
await loadVault(); await loadAdminData(); state.phase='app'; state.tab='vault';
|
||||
setMsg('Login success.', 'ok');
|
||||
}
|
||||
async function onSaveProfile(form){ var fd=new FormData(form); var n=String(fd.get('name')||'').trim(); var em=String(fd.get('email')||'').trim().toLowerCase(); var r=await authFetch('/api/accounts/profile',{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify({name:n,email:em})}); var j=await jsonOrNull(r); if(!r.ok) return setMsg((j&&(j.error||j.error_description))||'Save profile failed.', 'err'); state.profile=j; render(); setMsg('Profile updated.', 'ok'); }
|
||||
async function onEnableTotp(form){ var fd=new FormData(form); state.totpSetupSecret=String(fd.get('secret')||'').toUpperCase().replace(/[\\s-]/g,'').replace(/=+$/g,''); state.totpSetupToken=String(fd.get('token')||'').trim(); if(!state.totpSetupSecret) return setMsg('TOTP secret is required.', 'err'); if(!state.totpSetupToken) return setMsg('TOTP token is required.', 'err'); var r=await authFetch('/api/accounts/totp',{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify({enabled:true,secret:state.totpSetupSecret,token:state.totpSetupToken})}); var j=await jsonOrNull(r); if(!r.ok) return setMsg((j&&(j.error||j.error_description))||'Enable TOTP failed.', 'err'); state.totpSetupToken=''; render(); setMsg('TOTP enabled.', 'ok'); }
|
||||
function onDisableTotp(){ state.totpDisableOpen=true; state.totpDisablePassword=''; state.totpDisableError=''; render(); }
|
||||
async function onDisableTotpSubmit(form){
|
||||
var fd=new FormData(form); state.totpDisablePassword=String(fd.get('masterPassword')||'');
|
||||
if(!state.totpDisablePassword){ state.totpDisableError='Please input master password.'; render(); return; }
|
||||
try{
|
||||
var d=await deriveLoginHash(state.profile.email,state.totpDisablePassword);
|
||||
var r=await authFetch('/api/accounts/totp',{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify({enabled:false,masterPasswordHash:d.hash})});
|
||||
var j=await jsonOrNull(r);
|
||||
if(!r.ok){ state.totpDisableError=(j&&(j.error||j.error_description))||'Disable TOTP failed.'; render(); return; }
|
||||
state.totpDisableOpen=false; state.totpDisablePassword=''; state.totpDisableError='';
|
||||
render(); setMsg('TOTP disabled.', 'ok');
|
||||
}catch(e){
|
||||
state.totpDisableError='Disable TOTP failed: '+(e&&e.message?e.message:String(e));
|
||||
render();
|
||||
}
|
||||
}
|
||||
|
||||
async function onBulkDelete(){ var ids=[]; for(var k in state.selectedMap){ if(state.selectedMap[k]) ids.push(k);} if(ids.length===0) return setMsg('Select items first.', 'err'); if(!window.confirm('Delete selected '+ids.length+' items?')) return; for(var i=0;i<ids.length;i++) await authFetch('/api/ciphers/'+encodeURIComponent(ids[i]),{method:'DELETE'}); state.selectedMap={}; await loadVault(); render(); setMsg('Deleted selected items.', 'ok'); }
|
||||
async function onBulkMove(){ var ids=[]; for(var k in state.selectedMap){ if(state.selectedMap[k]) ids.push(k);} if(ids.length===0) return setMsg('Select items first.', 'err'); var opts=['0) No folder']; for(var i=0;i<state.folders.length;i++){ var f=state.folders[i]; var label=(f.decName||f.name||f.id); opts.push(String(i+1)+') '+String(label)); } var pick=window.prompt('Move selected items to:\\n'+opts.join('\\n')+'\\n\\nInput number (empty to cancel):','0'); if(pick===null) return; pick=String(pick).trim(); if(!pick) return; var idx=Number(pick); if(!Number.isInteger(idx)||idx<0||idx>state.folders.length) return setMsg('Invalid folder selection.', 'err'); var folderId=idx===0?null:state.folders[idx-1].id; var r=await authFetch('/api/ciphers/move',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({ids:ids,folderId:folderId})}); var j=await jsonOrNull(r); if(!r.ok) return setMsg((j&&(j.error||j.error_description))||'Bulk move failed.', 'err'); await loadVault(); render(); setMsg('Moved selected items.', 'ok'); }
|
||||
|
||||
async function onCreateInvite(form){ var fd=new FormData(form); var h=Number(fd.get('hours')||168); var r=await authFetch('/api/admin/invites',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({expiresInHours:h})}); var j=await jsonOrNull(r); if(!r.ok) return setMsg((j&&(j.error||j.error_description))||'Create invite failed.', 'err'); await loadAdminData(); render(); setMsg('Invite created.', 'ok'); }
|
||||
async function onToggleUserStatus(id,status){ var n=status==='active'?'banned':'active'; var r=await authFetch('/api/admin/users/'+encodeURIComponent(id)+'/status',{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify({status:n})}); var j=await jsonOrNull(r); if(!r.ok) return setMsg((j&&(j.error||j.error_description))||'Update user status failed.', 'err'); await loadAdminData(); render(); setMsg('User status updated.', 'ok'); }
|
||||
async function onDeleteUser(id){ if(!window.confirm('Delete this user and all user data?')) return; var r=await authFetch('/api/admin/users/'+encodeURIComponent(id),{method:'DELETE'}); var j=await jsonOrNull(r); if(!r.ok) return setMsg((j&&(j.error||j.error_description))||'Delete user failed.', 'err'); await loadAdminData(); render(); setMsg('User deleted.', 'ok'); }
|
||||
async function onRevokeInvite(code){ var r=await authFetch('/api/admin/invites/'+encodeURIComponent(code),{method:'DELETE'}); var j=await jsonOrNull(r); if(!r.ok) return setMsg((j&&(j.error||j.error_description))||'Revoke invite failed.', 'err'); await loadAdminData(); render(); setMsg('Invite revoked.', 'ok'); }
|
||||
|
||||
app.addEventListener('submit', function(ev){
|
||||
var form=ev.target; if(!(form instanceof HTMLFormElement)) return; ev.preventDefault();
|
||||
if(form.id==='registerForm') return void onRegister(form);
|
||||
if(form.id==='loginForm') return void onLoginPassword(form);
|
||||
if(form.id==='loginTotpForm') return void onLoginTotp(form);
|
||||
if(form.id==='profileForm') return void onSaveProfile(form);
|
||||
if(form.id==='totpEnableForm') return void onEnableTotp(form);
|
||||
if(form.id==='totpDisableForm') return void onDisableTotpSubmit(form);
|
||||
if(form.id==='inviteForm') return void onCreateInvite(form);
|
||||
});
|
||||
|
||||
app.addEventListener('click', function(ev){
|
||||
var n=ev.target; while(n&&n!==app&&!n.getAttribute('data-action')) n=n.parentElement; if(!n||n===app) return; var a=n.getAttribute('data-action'); if(!a) return;
|
||||
if(a==='goto-login'){ state.phase='login'; clearMsg(); render(); return; }
|
||||
if(a==='goto-register'){ state.phase='register'; clearMsg(); render(); return; }
|
||||
if(a==='logout'){ if(window.confirm('Log out now?')) logout(); return; }
|
||||
if(a==='totp-cancel'){ state.pendingLogin=null; state.loginTotpToken=''; state.loginTotpError=''; render(); return; }
|
||||
if(a==='totp-disable-cancel'){ state.totpDisableOpen=false; state.totpDisablePassword=''; state.totpDisableError=''; render(); return; }
|
||||
if(a==='tab'){ state.tab=n.getAttribute('data-tab')||'vault'; clearMsg(); render(); return; }
|
||||
if(a==='folder-filter'){ state.folderFilterId=n.getAttribute('data-folder')||''; var filtered=filteredCiphers(); state.selectedCipherId=filtered.length?filtered[0].id:''; render(); return; }
|
||||
if(a==='pick-cipher'){ state.selectedCipherId=n.getAttribute('data-id')||''; render(); return; }
|
||||
if(a==='toggle-select'){ ev.stopPropagation(); state.selectedMap[n.getAttribute('data-id')]=!!n.checked; render(); return; }
|
||||
if(a==='select-all'){ var list=filteredCiphers(); state.selectedMap={}; for(var i=0;i<list.length;i++) state.selectedMap[list[i].id]=true; render(); return; }
|
||||
if(a==='select-none'){ state.selectedMap={}; render(); return; }
|
||||
if(a==='bulk-delete') return void onBulkDelete();
|
||||
if(a==='bulk-move') return void onBulkMove();
|
||||
if(a==='vault-refresh'){ loadVault().then(function(){ render(); setMsg('Vault refreshed.', 'ok'); }).catch(function(e){ setMsg('Refresh failed: '+(e&&e.message?e.message:String(e)), 'err'); }); return; }
|
||||
if(a==='totp-secret-refresh'){ state.totpSetupSecret=randomBase32Secret(32); render(); return; }
|
||||
if(a==='totp-secret-copy'){ navigator.clipboard.writeText(currentTotpSecret()).then(function(){ setMsg('TOTP secret copied.', 'ok'); }).catch(function(){ setMsg('Copy failed.', 'err'); }); return; }
|
||||
if(a==='totp-disable'){ onDisableTotp(); return; }
|
||||
if(a==='admin-refresh'){ loadAdminData().then(function(){ render(); setMsg('Admin data refreshed.', 'ok'); }).catch(function(e){ setMsg('Refresh failed: '+(e&&e.message?e.message:String(e)), 'err'); }); return; }
|
||||
if(a==='user-toggle') return void onToggleUserStatus(n.getAttribute('data-id'),n.getAttribute('data-status'));
|
||||
if(a==='user-delete') return void onDeleteUser(n.getAttribute('data-id'));
|
||||
if(a==='invite-revoke') return void onRevokeInvite(n.getAttribute('data-code'));
|
||||
if(a==='invite-copy'){ var link=n.getAttribute('data-link')||''; navigator.clipboard.writeText(link).then(function(){ setMsg('Invite link copied.', 'ok'); }).catch(function(){ setMsg('Copy failed.', 'err'); }); return; }
|
||||
});
|
||||
|
||||
init();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
export async function handleWebClientPage(request: Request, env: Env): Promise<Response> {
|
||||
void request;
|
||||
void env;
|
||||
return htmlResponse(renderWebClientHTML());
|
||||
}
|
||||
+76
-9
@@ -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<Respons
|
||||
// Route matching
|
||||
try {
|
||||
|
||||
// Setup page (root)
|
||||
if (path === '/' && method === 'GET') {
|
||||
// Web client entry (single-path app)
|
||||
if ((path === '/' || path === '/register' || path === '/login') && method === 'GET') {
|
||||
return handleWebClientPage(request, env);
|
||||
}
|
||||
|
||||
// Legacy setup page
|
||||
if ((path === '/setup' || path === '/setup/legacy') && method === 'GET') {
|
||||
return handleSetupPage(request, env);
|
||||
}
|
||||
|
||||
@@ -277,7 +302,9 @@ export async function handleRequest(request: Request, env: Env): Promise<Respons
|
||||
return jsonResponse(LIMITS.compatibility.bitwardenServerVersion); // Always same value as /config.version
|
||||
}
|
||||
|
||||
// Registration endpoint (no auth required, but only works once)
|
||||
// Registration endpoint (no auth required):
|
||||
// - first user can self-register and becomes admin
|
||||
// - later registrations require inviteCode in request body
|
||||
if (path === '/api/accounts/register' && method === 'POST') {
|
||||
if (!isSameOriginWriteRequest(request)) {
|
||||
return errorResponse('Forbidden origin', 403);
|
||||
@@ -301,6 +328,14 @@ export async function handleRequest(request: Request, env: Env): Promise<Respons
|
||||
}
|
||||
|
||||
const userId = payload.sub;
|
||||
const storage = new StorageService(env.DB);
|
||||
const currentUser = await storage.getUserById(userId);
|
||||
if (!currentUser) {
|
||||
return errorResponse('Unauthorized', 401);
|
||||
}
|
||||
if (currentUser.status !== 'active') {
|
||||
return errorResponse('Account is disabled', 403);
|
||||
}
|
||||
const clientId = getClientIdentifier(request);
|
||||
|
||||
// Dedicated read rate limiting for heavy sync endpoint.
|
||||
@@ -344,19 +379,16 @@ export async function handleRequest(request: Request, env: Env): Promise<Respons
|
||||
}
|
||||
}
|
||||
|
||||
// Block account operations that could change password or delete user
|
||||
// Block account operations we do not support yet.
|
||||
if (method === 'POST' || method === 'PUT' || method === 'DELETE') {
|
||||
const blockedAccountPaths = new Set([
|
||||
'/api/accounts/password',
|
||||
'/api/accounts/change-password',
|
||||
'/api/accounts/set-password',
|
||||
'/api/accounts/master-password',
|
||||
'/api/accounts/delete',
|
||||
'/api/accounts/delete-account',
|
||||
'/api/accounts/delete-vault',
|
||||
]);
|
||||
if (blockedAccountPaths.has(path)) {
|
||||
return errorResponse('Not implemented in single-user mode', 501);
|
||||
return errorResponse('Not implemented', 501);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -366,10 +398,19 @@ export async function handleRequest(request: Request, env: Env): Promise<Respons
|
||||
if (method === 'PUT') return handleUpdateProfile(request, env, userId);
|
||||
}
|
||||
|
||||
if ((path === '/api/accounts/password' || path === '/api/accounts/change-password') && (method === 'POST' || method === 'PUT')) {
|
||||
return handleChangePassword(request, env, userId);
|
||||
}
|
||||
|
||||
if (path === '/api/accounts/keys' && method === 'POST') {
|
||||
return handleSetKeys(request, env, userId);
|
||||
}
|
||||
|
||||
if (path === '/api/accounts/totp') {
|
||||
if (method === 'GET') return handleGetTotpStatus(request, env, userId);
|
||||
if (method === 'PUT' || method === 'POST') return handleSetTotpStatus(request, env, userId);
|
||||
}
|
||||
|
||||
// Revision date endpoint
|
||||
if (path === '/api/accounts/revision-date' && method === 'GET') {
|
||||
return handleGetRevisionDate(request, env, userId);
|
||||
@@ -539,6 +580,32 @@ export async function handleRequest(request: Request, env: Env): Promise<Respons
|
||||
return handleGetDevices(request, env, userId);
|
||||
}
|
||||
|
||||
// Admin endpoints
|
||||
if (path === '/api/admin/users' && method === 'GET') {
|
||||
return handleAdminListUsers(request, env, currentUser);
|
||||
}
|
||||
|
||||
if (path === '/api/admin/invites') {
|
||||
if (method === 'GET') return handleAdminListInvites(request, env, currentUser);
|
||||
if (method === 'POST') return handleAdminCreateInvite(request, env, currentUser);
|
||||
}
|
||||
|
||||
const adminInviteMatch = path.match(/^\/api\/admin\/invites\/([^/]+)$/i);
|
||||
if (adminInviteMatch && method === 'DELETE') {
|
||||
const inviteCode = decodeURIComponent(adminInviteMatch[1]);
|
||||
return handleAdminRevokeInvite(request, env, currentUser, inviteCode);
|
||||
}
|
||||
|
||||
const adminUserStatusMatch = path.match(/^\/api\/admin\/users\/([a-f0-9-]+)\/status$/i);
|
||||
if (adminUserStatusMatch && (method === 'PUT' || method === 'POST')) {
|
||||
return handleAdminSetUserStatus(request, env, currentUser, adminUserStatusMatch[1]);
|
||||
}
|
||||
|
||||
const adminUserDeleteMatch = path.match(/^\/api\/admin\/users\/([a-f0-9-]+)$/i);
|
||||
if (adminUserDeleteMatch && method === 'DELETE') {
|
||||
return handleAdminDeleteUser(request, env, currentUser, adminUserDeleteMatch[1]);
|
||||
}
|
||||
|
||||
// Device push token endpoint (no-op compatibility handler)
|
||||
const deviceTokenMatch = path.match(/^\/api\/devices\/identifier\/([^/]+)\/token$/i);
|
||||
if (deviceTokenMatch && (method === 'PUT' || method === 'POST')) {
|
||||
|
||||
@@ -72,6 +72,10 @@ export class AuthService {
|
||||
|
||||
const user = await this.storage.getUserById(userId);
|
||||
if (!user) return null;
|
||||
if (user.status !== 'active') {
|
||||
await this.storage.deleteRefreshToken(refreshToken);
|
||||
return null;
|
||||
}
|
||||
|
||||
const accessToken = await this.generateAccessToken(user);
|
||||
return { accessToken, user };
|
||||
|
||||
+167
-32
@@ -1,4 +1,4 @@
|
||||
import { User, Cipher, Folder, Attachment, Device } from '../types';
|
||||
import { User, Cipher, Folder, Attachment, Device, Invite, AuditLog } from '../types';
|
||||
import { LIMITS } from '../config/limits';
|
||||
|
||||
const TWO_FACTOR_REMEMBER_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
||||
@@ -11,7 +11,10 @@ const SCHEMA_STATEMENTS: readonly string[] = [
|
||||
'id TEXT PRIMARY KEY, email TEXT NOT NULL UNIQUE, name TEXT, master_password_hash TEXT NOT NULL, ' +
|
||||
'key TEXT NOT NULL, private_key TEXT, public_key TEXT, kdf_type INTEGER NOT NULL, ' +
|
||||
'kdf_iterations INTEGER NOT NULL, kdf_memory INTEGER, kdf_parallelism INTEGER, ' +
|
||||
'security_stamp TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL)',
|
||||
'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)',
|
||||
'ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT \'user\'',
|
||||
'ALTER TABLE users ADD COLUMN status TEXT NOT NULL DEFAULT \'active\'',
|
||||
'ALTER TABLE users ADD COLUMN totp_secret TEXT',
|
||||
|
||||
'CREATE TABLE IF NOT EXISTS user_revisions (' +
|
||||
'user_id TEXT PRIMARY KEY, revision_date TEXT NOT NULL, ' +
|
||||
@@ -41,6 +44,19 @@ const SCHEMA_STATEMENTS: readonly string[] = [
|
||||
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)',
|
||||
'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, name TEXT NOT NULL, type INTEGER NOT NULL, ' +
|
||||
'created_at TEXT NOT NULL, updated_at TEXT NOT NULL, ' +
|
||||
@@ -132,6 +148,7 @@ export class StorageService {
|
||||
for (const stmt of SCHEMA_STATEMENTS) {
|
||||
await this.executeSchemaStatement(stmt);
|
||||
}
|
||||
await this.ensureAdminUserExists();
|
||||
|
||||
StorageService.schemaVerified = true;
|
||||
}
|
||||
@@ -149,6 +166,21 @@ export class StorageService {
|
||||
}
|
||||
}
|
||||
|
||||
private async ensureAdminUserExists(): Promise<void> {
|
||||
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<boolean> {
|
||||
@@ -164,14 +196,7 @@ export class StorageService {
|
||||
|
||||
// --- Users ---
|
||||
|
||||
async getUser(email: string): Promise<User | null> {
|
||||
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<any>();
|
||||
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<User | null> {
|
||||
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<any>();
|
||||
if (!row) return null;
|
||||
return this.mapUserRow(row);
|
||||
}
|
||||
|
||||
async getUserById(id: string): Promise<User | null> {
|
||||
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<any>();
|
||||
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<number> {
|
||||
const row = await this.db.prepare('SELECT COUNT(*) AS count FROM users').first<{ count: number }>();
|
||||
return Number(row?.count || 0);
|
||||
}
|
||||
|
||||
async getAllUsers(): Promise<User[]> {
|
||||
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<any>();
|
||||
return (res.results || []).map(row => this.mapUserRow(row));
|
||||
}
|
||||
|
||||
async saveUser(user: User): Promise<void> {
|
||||
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<void> {
|
||||
await this.saveUser(user);
|
||||
}
|
||||
|
||||
async createFirstUser(user: User): Promise<boolean> {
|
||||
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<boolean> {
|
||||
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<void> {
|
||||
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<Invite | null> {
|
||||
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<any>();
|
||||
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<Invite[]> {
|
||||
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<any>()
|
||||
: await this.db.prepare(query).bind(now).all<any>();
|
||||
|
||||
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<boolean> {
|
||||
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<boolean> {
|
||||
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<void> {
|
||||
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<Cipher | null> {
|
||||
@@ -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<void> {
|
||||
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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user