mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-20 21:00:41 +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);
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|
||||||
|
|||||||
@@ -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));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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 ---
|
||||||
|
|||||||
Reference in New Issue
Block a user