feat: enhance user registration and authentication flow, improve attachment handling, and strengthen security measures

This commit is contained in:
shuaiplus
2026-02-14 00:34:08 +08:00
parent b33ee64c58
commit 4772c17e44
8 changed files with 131 additions and 25 deletions
+8 -8
View File
@@ -27,12 +27,6 @@ export async function handleRegister(request: Request, env: Env): Promise<Respon
return errorResponse(message, 400); return errorResponse(message, 400);
} }
// Check if already registered
const isRegistered = await storage.isRegistered();
if (isRegistered) {
return errorResponse('Registration is closed', 403);
}
let body: { let body: {
email?: string; email?: string;
name?: string; name?: string;
@@ -88,7 +82,11 @@ export async function handleRegister(request: Request, env: Env): Promise<Respon
updatedAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
}; };
await storage.saveUser(user); const created = await storage.createFirstUser(user);
if (!created) {
return errorResponse('Registration is closed', 403);
}
await storage.setRegistered(); await storage.setRegistered();
return jsonResponse({ success: true }, 200); return jsonResponse({ success: true }, 200);
@@ -200,6 +198,7 @@ export async function handleGetRevisionDate(request: Request, env: Env, userId:
// POST /api/accounts/verify-password // POST /api/accounts/verify-password
export async function handleVerifyPassword(request: Request, env: Env, userId: string): Promise<Response> { export async function handleVerifyPassword(request: Request, env: Env, userId: string): Promise<Response> {
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
const auth = new AuthService(env);
const user = await storage.getUserById(userId); const user = await storage.getUserById(userId);
if (!user) { if (!user) {
@@ -217,7 +216,8 @@ export async function handleVerifyPassword(request: Request, env: Env, userId: s
return errorResponse('masterPasswordHash is required', 400); return errorResponse('masterPasswordHash is required', 400);
} }
if (body.masterPasswordHash !== user.masterPasswordHash) { const valid = await auth.verifyPassword(body.masterPasswordHash, user.masterPasswordHash);
if (!valid) {
return errorResponse('Invalid password', 400); return errorResponse('Invalid password', 400);
} }
+6 -1
View File
@@ -1,4 +1,4 @@
import { Env, Attachment } from '../types'; import { Env, Attachment, DEFAULT_DEV_SECRET } from '../types';
import { StorageService } from '../services/storage'; import { StorageService } from '../services/storage';
import { jsonResponse, errorResponse } from '../utils/response'; import { jsonResponse, errorResponse } from '../utils/response';
import { generateUUID } from '../utils/uuid'; import { generateUUID } from '../utils/uuid';
@@ -210,6 +210,11 @@ export async function handlePublicDownloadAttachment(
cipherId: string, cipherId: string,
attachmentId: string attachmentId: string
): Promise<Response> { ): Promise<Response> {
const secret = (env.JWT_SECRET || '').trim();
if (!secret || secret.length < 32 || secret === DEFAULT_DEV_SECRET) {
return errorResponse('Server configuration error', 500);
}
const url = new URL(request.url); const url = new URL(request.url);
const token = url.searchParams.get('token'); const token = url.searchParams.get('token');
+3 -1
View File
@@ -68,10 +68,12 @@ export async function handleGetCiphers(request: Request, env: Env, userId: strin
? ciphers ? ciphers
: ciphers.filter(c => !c.deletedAt); : ciphers.filter(c => !c.deletedAt);
const attachmentsByCipher = await storage.getAttachmentsByCipherIds(filteredCiphers.map(c => c.id));
// Get attachments for all ciphers // Get attachments for all ciphers
const cipherResponses = []; const cipherResponses = [];
for (const cipher of filteredCiphers) { for (const cipher of filteredCiphers) {
const attachments = await storage.getAttachmentsByCipher(cipher.id); const attachments = attachmentsByCipher.get(cipher.id) || [];
cipherResponses.push(cipherToResponse(cipher, attachments)); cipherResponses.push(cipherToResponse(cipher, attachments));
} }
+7 -6
View File
@@ -32,12 +32,7 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
return identityErrorResponse('Email and password are required', 'invalid_request', 400); return identityErrorResponse('Email and password are required', 'invalid_request', 400);
} }
const user = await storage.getUser(email); // Check login lockout before user lookup to reduce user-enumeration signal
if (!user) {
return identityErrorResponse('Username or password is incorrect. Try again', 'invalid_grant', 400);
}
// Check if login is rate limited (only after confirming user exists)
const loginCheck = await rateLimit.checkLoginAttempt(email); const loginCheck = await rateLimit.checkLoginAttempt(email);
if (!loginCheck.allowed) { if (!loginCheck.allowed) {
return identityErrorResponse( return identityErrorResponse(
@@ -47,6 +42,12 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
); );
} }
const user = await storage.getUser(email);
if (!user) {
await rateLimit.recordFailedLogin(email);
return identityErrorResponse('Username or password is incorrect. Try again', 'invalid_grant', 400);
}
const valid = await auth.verifyPassword(passwordHash, user.masterPasswordHash); const valid = await auth.verifyPassword(passwordHash, user.masterPasswordHash);
if (!valid) { if (!valid) {
// Record failed login attempt // Record failed login attempt
+2 -1
View File
@@ -14,6 +14,7 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr
const ciphers = await storage.getAllCiphers(userId); const ciphers = await storage.getAllCiphers(userId);
const folders = await storage.getAllFolders(userId); const folders = await storage.getAllFolders(userId);
const attachmentsByCipher = await storage.getAttachmentsByCipherIds(ciphers.map(c => c.id));
// Build profile response // Build profile response
const profile: ProfileResponse = { const profile: ProfileResponse = {
@@ -43,7 +44,7 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr
// Build cipher responses with attachments // Build cipher responses with attachments
const cipherResponses: CipherResponse[] = []; const cipherResponses: CipherResponse[] = [];
for (const cipher of ciphers) { for (const cipher of ciphers) {
const attachments = await storage.getAttachmentsByCipher(cipher.id); const attachments = attachmentsByCipher.get(cipher.id) || [];
cipherResponses.push(cipherToResponse(cipher, attachments)); cipherResponses.push(cipherToResponse(cipher, attachments));
} }
+2 -2
View File
@@ -1,4 +1,4 @@
import { Env } from './types'; import { Env, DEFAULT_DEV_SECRET } from './types';
import { AuthService } from './services/auth'; import { AuthService } from './services/auth';
import { RateLimitService, getClientIdentifier } from './services/ratelimit'; import { RateLimitService, getClientIdentifier } from './services/ratelimit';
import { handleCors, errorResponse, jsonResponse } from './utils/response'; import { handleCors, errorResponse, jsonResponse } from './utils/response';
@@ -184,7 +184,7 @@ export async function handleRequest(request: Request, env: Env): Promise<Respons
// If JWT_SECRET is not safely configured, block any other endpoints. // If JWT_SECRET is not safely configured, block any other endpoints.
const secret = (env.JWT_SECRET || '').trim(); const secret = (env.JWT_SECRET || '').trim();
if (!secret || secret.length < 32) { if (!secret || secret.length < 32 || secret === DEFAULT_DEV_SECRET) {
return errorResponse('Server configuration error: JWT_SECRET is not set or too weak', 500); return errorResponse('Server configuration error: JWT_SECRET is not set or too weak', 500);
} }
+9 -3
View File
@@ -11,9 +11,15 @@ export class AuthService {
// Verify password hash (compare with stored hash) // Verify password hash (compare with stored hash)
async verifyPassword(inputHash: string, storedHash: string): Promise<boolean> { async verifyPassword(inputHash: string, storedHash: string): Promise<boolean> {
// In Bitwarden, the client sends the password hash directly const input = new TextEncoder().encode(inputHash);
// We compare the hashes const stored = new TextEncoder().encode(storedHash);
return inputHash === storedHash; if (input.length !== stored.length) return false;
let diff = 0;
for (let i = 0; i < input.length; i++) {
diff |= input[i] ^ stored[i];
}
return diff === 0;
} }
// Generate access token // Generate access token
+94 -3
View File
@@ -9,6 +9,17 @@ import { User, Cipher, Folder, Attachment } from '../types';
export class StorageService { export class StorageService {
constructor(private db: D1Database) {} constructor(private db: D1Database) {}
private async sha256Hex(input: string): Promise<string> {
const bytes = new TextEncoder().encode(input);
const digest = await crypto.subtle.digest('SHA-256', bytes);
return Array.from(new Uint8Array(digest)).map(b => b.toString(16).padStart(2, '0')).join('');
}
private async refreshTokenKey(token: string): Promise<string> {
const digest = await this.sha256Hex(token);
return `sha256:${digest}`;
}
// --- Database initialization --- // --- Database initialization ---
// Idempotent auto-init for environments where D1 migrations have not been applied // Idempotent auto-init for environments where D1 migrations have not been applied
// (e.g. one-click deploy). Mirrors the schema in migrations/0001_init.sql — // (e.g. one-click deploy). Mirrors the schema in migrations/0001_init.sql —
@@ -245,6 +256,35 @@ CREATE INDEX IF NOT EXISTS idx_api_rate_window ON api_rate_limits(window_start);
.run(); .run();
} }
async createFirstUser(user: User): Promise<boolean> {
const email = user.email.toLowerCase();
const result = await 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 ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? ' +
'WHERE NOT EXISTS (SELECT 1 FROM users LIMIT 1)'
)
.bind(
user.id,
email,
user.name,
user.masterPasswordHash,
user.key,
user.privateKey,
user.publicKey,
user.kdfType,
user.kdfIterations,
user.kdfMemory ?? null,
user.kdfParallelism ?? null,
user.securityStamp,
user.createdAt,
user.updatedAt
)
.run();
return (result.meta.changes ?? 0) > 0;
}
// --- Ciphers --- // --- Ciphers ---
async getCipher(id: string): Promise<Cipher | null> { async getCipher(id: string): Promise<Cipher | null> {
@@ -412,6 +452,36 @@ CREATE INDEX IF NOT EXISTS idx_api_rate_window ON api_rate_limits(window_start);
})); }));
} }
async getAttachmentsByCipherIds(cipherIds: string[]): Promise<Map<string, Attachment[]>> {
const grouped = new Map<string, Attachment[]>();
if (cipherIds.length === 0) return grouped;
const placeholders = cipherIds.map(() => '?').join(',');
const res = await this.db
.prepare(`SELECT id, cipher_id, file_name, size, size_name, key FROM attachments WHERE cipher_id IN (${placeholders})`)
.bind(...cipherIds)
.all<any>();
for (const row of (res.results || [])) {
const item: Attachment = {
id: row.id,
cipherId: row.cipher_id,
fileName: row.file_name,
size: row.size,
sizeName: row.size_name,
key: row.key,
};
const list = grouped.get(item.cipherId);
if (list) {
list.push(item);
} else {
grouped.set(item.cipherId, [item]);
}
}
return grouped;
}
async addAttachmentToCipher(cipherId: string, attachmentId: string): Promise<void> { async addAttachmentToCipher(cipherId: string, attachmentId: string): Promise<void> {
// Kept for API compatibility; no-op because attachments table already links cipher_id. // Kept for API compatibility; no-op because attachments table already links cipher_id.
// We still validate that the attachment exists and belongs to cipher. // We still validate that the attachment exists and belongs to cipher.
@@ -441,20 +511,39 @@ CREATE INDEX IF NOT EXISTS idx_api_rate_window ON api_rate_limits(window_start);
async saveRefreshToken(token: string, userId: string, expiresAtMs?: number): Promise<void> { async saveRefreshToken(token: string, userId: string, expiresAtMs?: number): Promise<void> {
const expiresAt = expiresAtMs ?? (Date.now() + 30 * 24 * 60 * 60 * 1000); const expiresAt = expiresAtMs ?? (Date.now() + 30 * 24 * 60 * 60 * 1000);
const tokenKey = await this.refreshTokenKey(token);
await this.db.prepare( await this.db.prepare(
'INSERT INTO refresh_tokens(token, user_id, expires_at) VALUES(?, ?, ?) ' + 'INSERT INTO refresh_tokens(token, user_id, expires_at) VALUES(?, ?, ?) ' +
'ON CONFLICT(token) DO UPDATE SET user_id=excluded.user_id, expires_at=excluded.expires_at' 'ON CONFLICT(token) DO UPDATE SET user_id=excluded.user_id, expires_at=excluded.expires_at'
) )
.bind(token, userId, expiresAt) .bind(tokenKey, userId, expiresAt)
.run(); .run();
} }
async getRefreshTokenUserId(token: string): Promise<string | null> { async getRefreshTokenUserId(token: string): Promise<string | null> {
const now = Date.now(); const now = Date.now();
const row = await this.db.prepare('SELECT user_id, expires_at FROM refresh_tokens WHERE token = ?') const tokenKey = await this.refreshTokenKey(token);
.bind(token)
let row = await this.db.prepare('SELECT user_id, expires_at FROM refresh_tokens WHERE token = ?')
.bind(tokenKey)
.first<{ user_id: string; expires_at: number }>(); .first<{ user_id: string; expires_at: number }>();
if (!row) {
const legacyRow = await this.db.prepare('SELECT user_id, expires_at FROM refresh_tokens WHERE token = ?')
.bind(token)
.first<{ user_id: string; expires_at: number }>();
if (legacyRow) {
if (legacyRow.expires_at && legacyRow.expires_at < now) {
await this.deleteRefreshToken(token);
return null;
}
await this.saveRefreshToken(token, legacyRow.user_id, legacyRow.expires_at);
await this.db.prepare('DELETE FROM refresh_tokens WHERE token = ?').bind(token).run();
return legacyRow.user_id;
}
}
if (!row) return null; if (!row) return null;
if (row.expires_at && row.expires_at < now) { if (row.expires_at && row.expires_at < now) {
await this.deleteRefreshToken(token); await this.deleteRefreshToken(token);
@@ -464,7 +553,9 @@ CREATE INDEX IF NOT EXISTS idx_api_rate_window ON api_rate_limits(window_start);
} }
async deleteRefreshToken(token: string): Promise<void> { async deleteRefreshToken(token: string): Promise<void> {
const tokenKey = await this.refreshTokenKey(token);
await this.db.prepare('DELETE FROM refresh_tokens WHERE token = ?').bind(token).run(); await this.db.prepare('DELETE FROM refresh_tokens WHERE token = ?').bind(token).run();
await this.db.prepare('DELETE FROM refresh_tokens WHERE token = ?').bind(tokenKey).run();
} }
// --- Revision dates --- // --- Revision dates ---