mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-20 13:00:39 +00:00
feat: enhance user registration and authentication flow, improve attachment handling, and strengthen security measures
This commit is contained in:
@@ -27,12 +27,6 @@ export async function handleRegister(request: Request, env: Env): Promise<Respon
|
||||
return errorResponse(message, 400);
|
||||
}
|
||||
|
||||
// Check if already registered
|
||||
const isRegistered = await storage.isRegistered();
|
||||
if (isRegistered) {
|
||||
return errorResponse('Registration is closed', 403);
|
||||
}
|
||||
|
||||
let body: {
|
||||
email?: string;
|
||||
name?: string;
|
||||
@@ -88,7 +82,11 @@ export async function handleRegister(request: Request, env: Env): Promise<Respon
|
||||
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();
|
||||
|
||||
return jsonResponse({ success: true }, 200);
|
||||
@@ -200,6 +198,7 @@ export async function handleGetRevisionDate(request: Request, env: Env, userId:
|
||||
// POST /api/accounts/verify-password
|
||||
export async function handleVerifyPassword(request: Request, env: Env, userId: string): Promise<Response> {
|
||||
const storage = new StorageService(env.DB);
|
||||
const auth = new AuthService(env);
|
||||
const user = await storage.getUserById(userId);
|
||||
|
||||
if (!user) {
|
||||
@@ -217,7 +216,8 @@ export async function handleVerifyPassword(request: Request, env: Env, userId: s
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Env, Attachment } from '../types';
|
||||
import { Env, Attachment, DEFAULT_DEV_SECRET } from '../types';
|
||||
import { StorageService } from '../services/storage';
|
||||
import { jsonResponse, errorResponse } from '../utils/response';
|
||||
import { generateUUID } from '../utils/uuid';
|
||||
@@ -210,6 +210,11 @@ export async function handlePublicDownloadAttachment(
|
||||
cipherId: string,
|
||||
attachmentId: string
|
||||
): 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 token = url.searchParams.get('token');
|
||||
|
||||
|
||||
@@ -68,10 +68,12 @@ export async function handleGetCiphers(request: Request, env: Env, userId: strin
|
||||
? ciphers
|
||||
: ciphers.filter(c => !c.deletedAt);
|
||||
|
||||
const attachmentsByCipher = await storage.getAttachmentsByCipherIds(filteredCiphers.map(c => c.id));
|
||||
|
||||
// Get attachments for all ciphers
|
||||
const cipherResponses = [];
|
||||
for (const cipher of filteredCiphers) {
|
||||
const attachments = await storage.getAttachmentsByCipher(cipher.id);
|
||||
const attachments = attachmentsByCipher.get(cipher.id) || [];
|
||||
cipherResponses.push(cipherToResponse(cipher, attachments));
|
||||
}
|
||||
|
||||
|
||||
@@ -32,12 +32,7 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
||||
return identityErrorResponse('Email and password are required', 'invalid_request', 400);
|
||||
}
|
||||
|
||||
const user = await storage.getUser(email);
|
||||
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)
|
||||
// Check login lockout before user lookup to reduce user-enumeration signal
|
||||
const loginCheck = await rateLimit.checkLoginAttempt(email);
|
||||
if (!loginCheck.allowed) {
|
||||
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);
|
||||
if (!valid) {
|
||||
// Record failed login attempt
|
||||
|
||||
@@ -14,6 +14,7 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr
|
||||
|
||||
const ciphers = await storage.getAllCiphers(userId);
|
||||
const folders = await storage.getAllFolders(userId);
|
||||
const attachmentsByCipher = await storage.getAttachmentsByCipherIds(ciphers.map(c => c.id));
|
||||
|
||||
// Build profile response
|
||||
const profile: ProfileResponse = {
|
||||
@@ -43,7 +44,7 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr
|
||||
// Build cipher responses with attachments
|
||||
const cipherResponses: CipherResponse[] = [];
|
||||
for (const cipher of ciphers) {
|
||||
const attachments = await storage.getAttachmentsByCipher(cipher.id);
|
||||
const attachments = attachmentsByCipher.get(cipher.id) || [];
|
||||
cipherResponses.push(cipherToResponse(cipher, attachments));
|
||||
}
|
||||
|
||||
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
import { Env } from './types';
|
||||
import { Env, DEFAULT_DEV_SECRET } from './types';
|
||||
import { AuthService } from './services/auth';
|
||||
import { RateLimitService, getClientIdentifier } from './services/ratelimit';
|
||||
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.
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@@ -11,9 +11,15 @@ export class AuthService {
|
||||
|
||||
// Verify password hash (compare with stored hash)
|
||||
async verifyPassword(inputHash: string, storedHash: string): Promise<boolean> {
|
||||
// In Bitwarden, the client sends the password hash directly
|
||||
// We compare the hashes
|
||||
return inputHash === storedHash;
|
||||
const input = new TextEncoder().encode(inputHash);
|
||||
const stored = new TextEncoder().encode(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
|
||||
|
||||
+94
-3
@@ -9,6 +9,17 @@ import { User, Cipher, Folder, Attachment } from '../types';
|
||||
export class StorageService {
|
||||
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 ---
|
||||
// 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 —
|
||||
@@ -245,6 +256,35 @@ CREATE INDEX IF NOT EXISTS idx_api_rate_window ON api_rate_limits(window_start);
|
||||
.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 ---
|
||||
|
||||
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> {
|
||||
// Kept for API compatibility; no-op because attachments table already links cipher_id.
|
||||
// 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> {
|
||||
const expiresAt = expiresAtMs ?? (Date.now() + 30 * 24 * 60 * 60 * 1000);
|
||||
const tokenKey = await this.refreshTokenKey(token);
|
||||
await this.db.prepare(
|
||||
'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'
|
||||
)
|
||||
.bind(token, userId, expiresAt)
|
||||
.bind(tokenKey, userId, expiresAt)
|
||||
.run();
|
||||
}
|
||||
|
||||
async getRefreshTokenUserId(token: string): Promise<string | null> {
|
||||
const now = Date.now();
|
||||
const row = await this.db.prepare('SELECT user_id, expires_at FROM refresh_tokens WHERE token = ?')
|
||||
.bind(token)
|
||||
const tokenKey = await this.refreshTokenKey(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 }>();
|
||||
|
||||
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.expires_at && row.expires_at < now) {
|
||||
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> {
|
||||
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(tokenKey).run();
|
||||
}
|
||||
|
||||
// --- Revision dates ---
|
||||
|
||||
Reference in New Issue
Block a user