diff --git a/.env.example b/.env.example index 724caba2..4e594d9b 100644 --- a/.env.example +++ b/.env.example @@ -8,6 +8,10 @@ NEXT_PUBLIC_SETUP_COMPLETE=true # Required authentication secret. Replace with a long random value in production. SESSION_SECRET=change-this-to-a-long-random-production-secret +# Password hashing work factor. Benchmark on the production host before increasing. +# Allowed range: 10-15. The application default is 12. +BCRYPT_COST=12 + # PostgreSQL database URLs AUTH_DATABASE_URL=postgresql://postgres:hurc123@postgres:5432/hurc_auth AI_DATABASE_URL=postgresql://postgres:hurc123@postgres:5432/hurc_ai diff --git a/package.json b/package.json index 2408a880..d10849ed 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "test:ai-governance": "npx tsx src/scripts/test-ai-governance.ts", "test:guard": "node scripts/test-guard.js", "security:rotate-compromised-admins": "npx tsx src/scripts/rotate-compromised-admin-credentials.ts", + "security:benchmark-password-hashing": "npx tsx src/scripts/benchmark-password-hashing.ts", "db:dashboard": "npx tsx src/scripts/generate-integrity-dashboard.ts" }, "dependencies": { diff --git a/src/lib/security/password-hashing.ts b/src/lib/security/password-hashing.ts new file mode 100644 index 00000000..9da2527d --- /dev/null +++ b/src/lib/security/password-hashing.ts @@ -0,0 +1,62 @@ +import bcrypt from 'bcryptjs'; + +export const DEFAULT_BCRYPT_COST = 12; +export const MIN_BCRYPT_COST = 10; +export const MAX_BCRYPT_COST = 15; + +const BCRYPT_HASH_PATTERN = /^\$2[aby]\$(\d{2})\$/; + +/** + * Returns the configured bcrypt work factor. + * + * The bounded range prevents accidental weak settings and protects the + * authentication service from an excessively expensive configuration. + */ +export function getPasswordHashCost(): number { + const configuredCost = process.env.BCRYPT_COST?.trim(); + if (!configuredCost) return DEFAULT_BCRYPT_COST; + + const parsedCost = Number(configuredCost); + if ( + !Number.isInteger(parsedCost) || + parsedCost < MIN_BCRYPT_COST || + parsedCost > MAX_BCRYPT_COST + ) { + throw new Error( + `BCRYPT_COST must be an integer between ${MIN_BCRYPT_COST} and ${MAX_BCRYPT_COST}.` + ); + } + + return parsedCost; +} + +export function isBcryptHash(value: unknown): value is string { + return typeof value === 'string' && BCRYPT_HASH_PATTERN.test(value); +} + +export async function hashPassword(password: string): Promise { + return bcrypt.hash(password, getPasswordHashCost()); +} + +export async function verifyPassword(password: string, encodedHash: unknown): Promise { + if (!isBcryptHash(encodedHash)) return false; + + try { + return await bcrypt.compare(password, encodedHash); + } catch { + // Treat malformed or unsupported hashes as invalid credentials. + return false; + } +} + +/** + * Existing hashes remain valid. A successful login can transparently replace + * a lower-cost hash with the currently configured cost. + */ +export function passwordHashNeedsUpgrade(encodedHash: unknown): boolean { + if (!isBcryptHash(encodedHash)) return false; + + const match = BCRYPT_HASH_PATTERN.exec(encodedHash); + const currentCost = match ? Number(match[1]) : Number.NaN; + return Number.isInteger(currentCost) && currentCost < getPasswordHashCost(); +} diff --git a/src/lib/services/user-access-service.ts b/src/lib/services/user-access-service.ts new file mode 100644 index 00000000..6ec39fa9 --- /dev/null +++ b/src/lib/services/user-access-service.ts @@ -0,0 +1,117 @@ +import { authDb, IS_DATABASE_OFFLINE } from '../prisma'; +import { jsonDb } from '../db/json-db'; + +export async function getInternalPasswordResetRequests() { + if (!IS_DATABASE_OFFLINE) { + try { + return await authDb.passwordResetRequest.findMany({ where: { status: 'pending' } }); + } catch (e) {} + } + + const all = await jsonDb.getCollection('password_reset_requests'); + return all.filter((request: any) => request.status === 'pending'); +} + +export async function createInternalPasswordResetRequest( + userId: string, + email: string, + name: string, +) { + const record = { + id: `pwr-${Date.now()}`, + userId, + userEmail: email, + userName: name, + status: 'pending', + createdAt: new Date().toISOString(), + }; + + if (!IS_DATABASE_OFFLINE) { + try { + await authDb.passwordResetRequest.create({ + data: { + ...record, + createdAt: new Date(record.createdAt), + }, + }); + return; + } catch (error) { + console.error('[USER-ACCESS] PostgreSQL create password reset request failed:', error); + throw error; + } + } + + await jsonDb.insertRecord('password_reset_requests', record); +} + +export async function updateInternalPasswordResetRequest(id: string, status: string) { + if (!IS_DATABASE_OFFLINE) { + try { + await authDb.passwordResetRequest.update({ + where: { id }, + data: { status }, + }); + return; + } catch (error) { + console.error('[USER-ACCESS] PostgreSQL update password reset request failed:', error); + throw error; + } + } + + await jsonDb.updateRecord('password_reset_requests', id, { status }); +} + +export async function getInternalRoles() { + if (!IS_DATABASE_OFFLINE) { + try { + const roles = await authDb.role.findMany(); + if (roles.length > 0) return roles; + } catch (error) { + console.warn('[USER-ACCESS] DB unreachable during getInternalRoles, checking local store.'); + } + } + + return jsonDb.getCollection('roles'); +} + +export async function createInternalRole(data: any) { + if (!IS_DATABASE_OFFLINE) { + try { + await authDb.role.create({ data }); + return; + } catch (error) { + console.error('[USER-ACCESS] PostgreSQL create role failed:', error); + throw error; + } + } + + await jsonDb.insertRecord('roles', data); +} + +export async function updateInternalRole(id: string, data: any) { + if (!IS_DATABASE_OFFLINE) { + try { + await authDb.role.update({ where: { id }, data }); + return; + } catch (error) { + console.error('[USER-ACCESS] PostgreSQL update role failed:', error); + throw error; + } + } + + await jsonDb.updateRecord('roles', id, data); +} + +export async function deleteInternalRole(id: string) { + if (!IS_DATABASE_OFFLINE) { + try { + await authDb.role.delete({ where: { id } }); + return; + } catch (error) { + console.error('[USER-ACCESS] PostgreSQL delete role failed:', error); + throw error; + } + } + + await jsonDb.delete('roles', (role: any) => role.id === id); +} diff --git a/src/lib/services/user-password-service.ts b/src/lib/services/user-password-service.ts new file mode 100644 index 00000000..5a2c0bb2 --- /dev/null +++ b/src/lib/services/user-password-service.ts @@ -0,0 +1,72 @@ +import crypto from 'crypto'; +import { authDb, IS_DATABASE_OFFLINE } from '../prisma'; +import { jsonDb } from '../db/json-db'; +import { hashPassword } from '../security/password-hashing'; + +export function validatePassword(password: string): { isValid: boolean; message?: string } { + if (password.length < 10) { + return { isValid: false, message: 'Mật khẩu phải có ít nhất 10 ký tự.' }; + } + if (!/[A-Z]/.test(password)) { + return { isValid: false, message: 'Mật khẩu phải chứa ít nhất một chữ hoa.' }; + } + if (!/[a-z]/.test(password)) { + return { isValid: false, message: 'Mật khẩu phải chứa ít nhất một chữ thường.' }; + } + if (!/[0-9]/.test(password)) { + return { isValid: false, message: 'Mật khẩu phải chứa ít nhất một chữ số.' }; + } + if (!/[^A-Za-z0-9]/.test(password)) { + return { isValid: false, message: 'Mật khẩu phải chứa ít nhất một ký tự đặc biệt.' }; + } + + return { isValid: true }; +} + +export function generateRandomPassword(length = 8): string { + const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; + let password = 'HURC-'; + + for (let index = 0; index < length; index += 1) { + password += chars.charAt(crypto.randomInt(0, chars.length)); + } + + return password; +} + +export async function updateUserPassword( + userId: string, + newPassword: string, + _adminId?: string, +) { + const validation = validatePassword(newPassword); + if (!validation.isValid) { + throw new Error(validation.message); + } + + const hashedPassword = await hashPassword(newPassword); + const changedAt = new Date().toISOString(); + const updateData = { + password: hashedPassword, + passwordLastChangedAt: changedAt, + mustChangePassword: false, + updatedAt: changedAt, + }; + + if (!IS_DATABASE_OFFLINE) { + try { + return await authDb.user.update({ + where: { id: userId }, + data: { + password: updateData.password, + passwordLastChangedAt: new Date(changedAt), + }, + }); + } catch (error) { + console.error('[USER-PASSWORD] PostgreSQL update user password failed:', error); + throw error; + } + } + + return jsonDb.updateRecord('users', userId, updateData); +} diff --git a/src/lib/services/user-service.ts b/src/lib/services/user-service.ts index 6357b3a8..c525e65c 100644 --- a/src/lib/services/user-service.ts +++ b/src/lib/services/user-service.ts @@ -1,13 +1,32 @@ import { authDb, IS_DATABASE_OFFLINE } from '../prisma'; import { jsonDb } from '../db/json-db'; import { type User } from '../constants'; -import bcrypt from 'bcryptjs'; import crypto from 'crypto'; +import { + hashPassword, + isBcryptHash, + passwordHashNeedsUpgrade, + verifyPassword, +} from '../security/password-hashing'; + +export { + createInternalPasswordResetRequest, + createInternalRole, + deleteInternalRole, + getInternalPasswordResetRequests, + getInternalRoles, + updateInternalPasswordResetRequest, + updateInternalRole, +} from './user-access-service'; +export { + generateRandomPassword, + updateUserPassword, + validatePassword, +} from './user-password-service'; /** * CORE LOGIC ONLY - NO 'use server' - * This service handles all data persistence and business logic for Users and Roles. - * Refactored for Phase 4: Atomic Offline Operations. + * Handles user persistence, credential verification, and account metadata. */ function omitPassword(user: any): User { @@ -15,75 +34,75 @@ function omitPassword(user: any): User { return userWithoutPassword as User; } -/** - * VALIDATION: Centralized password strength check - */ -export function validatePassword(password: string): { isValid: boolean; message?: string } { - if (password.length < 10) return { isValid: false, message: "Mật khẩu phải có ít nhất 10 ký tự." }; - if (!/[A-Z]/.test(password)) return { isValid: false, message: "Mật khẩu phải chứa ít nhất một chữ hoa." }; - if (!/[a-z]/.test(password)) return { isValid: false, message: "Mật khẩu phải chứa ít nhất một chữ thường." }; - if (!/[0-9]/.test(password)) return { isValid: false, message: "Mật khẩu phải chứa ít nhất một chữ số." }; - if (!/[^A-Za-z0-9]/.test(password)) return { isValid: false, message: "Mật khẩu phải chứa ít nhất một ký tự đặc biệt." }; - return { isValid: true }; -} - /** * LOGIN VERIFICATION: Enhanced with Brute Force Protection & Metadata Updates */ -export async function verifyInternalCredentials(email: string, password?: string, ip?: string): Promise<{ user?: User; error?: string }> { - if (!password) return { error: "Mật khẩu không được để trống." }; - +export async function verifyInternalCredentials( + email: string, + password?: string, + ip?: string, +): Promise<{ user?: User; error?: string }> { + if (!password) return { error: 'Mật khẩu không được để trống.' }; + let dbUser: any = null; const now = new Date(); if (!IS_DATABASE_OFFLINE) { try { dbUser = await authDb.user.findUnique({ where: { email } }); - } catch (e) { - console.warn("[USER-SERVICE] DB unreachable during login, checking local store."); + } catch (error) { + console.warn('[USER-SERVICE] DB unreachable during login, checking local store.'); } } if (!dbUser) { - dbUser = await jsonDb.findFirst('users', (u: any) => u.email === email); + dbUser = await jsonDb.findFirst('users', (user: any) => user.email === email); } - if (!dbUser) return { error: "Email hoặc mật khẩu không chính xác." }; + if (!dbUser) return { error: 'Email hoặc mật khẩu không chính xác.' }; - // Check Account Status if (dbUser.status !== 'active') { - return { error: "Tài khoản đã bị vô hiệu hóa hoặc tạm khóa. Vui lòng liên hệ quản trị viên." }; + return { error: 'Tài khoản đã bị vô hiệu hóa hoặc tạm khóa. Vui lòng liên hệ quản trị viên.' }; } - // Check Lockout if (dbUser.lockoutUntil && new Date(dbUser.lockoutUntil) > now) { - const remaining = Math.ceil((new Date(dbUser.lockoutUntil).getTime() - now.getTime()) / 60000); + const remaining = Math.ceil( + (new Date(dbUser.lockoutUntil).getTime() - now.getTime()) / 60000, + ); return { error: `Tài khoản đã bị khóa. Vui lòng thử lại sau ${remaining} phút.` }; } - const isValid = await bcrypt.compare(password, dbUser.password); - + const isValid = await verifyPassword(password, dbUser.password); + if (!isValid) { const failedAttempts = (dbUser.failedLoginAttempts || 0) + 1; const updateData: any = { failedLoginAttempts: failedAttempts }; - + if (failedAttempts >= 5) { updateData.lockoutUntil = new Date(now.getTime() + 15 * 60000).toISOString(); updateData.failedLoginAttempts = 0; } - + await updateInternalUser(dbUser.id, updateData); - return { error: "Email hoặc mật khẩu không chính xác." }; + return { error: 'Email hoặc mật khẩu không chính xác.' }; } - // Success - Update Metadata const updateSuccess: any = { failedLoginAttempts: 0, lockoutUntil: null, lastLoginAt: now.toISOString(), lastLoginIp: ip || 'unknown', }; - + + if (passwordHashNeedsUpgrade(dbUser.password)) { + try { + updateSuccess.password = await hashPassword(password); + } catch (error) { + // Rehash failure must not prevent an otherwise valid login. + console.error('[USER-SERVICE] Password hash upgrade failed:', error); + } + } + await updateInternalUser(dbUser.id, updateSuccess); return { user: omitPassword({ ...dbUser, ...updateSuccess }) }; } @@ -91,18 +110,17 @@ export async function verifyInternalCredentials(email: string, password?: string export async function getInternalUsers(): Promise { if (!IS_DATABASE_OFFLINE) { try { - const users = await authDb.user.findMany({ - orderBy: { name: 'asc' } - }); + const users = await authDb.user.findMany({ orderBy: { name: 'asc' } }); return users.map(omitPassword) as unknown as User[]; - } catch (e) {} + } catch (error) {} } + const users = await jsonDb.getCollection('users'); return users.map(omitPassword) as unknown as User[]; } export async function createInternalUser(user: Partial) { - const hashedPassword = await bcrypt.hash(user.password || 'default123', 10); + const hashedPassword = await hashPassword(user.password || 'default123'); const otp = crypto.randomInt(100000, 999999).toString(); const expiry = new Date(Date.now() + 15 * 60000); @@ -112,7 +130,7 @@ export async function createInternalUser(user: Partial) { email: user.email, password: hashedPassword, role: user.role, - status: "active", + status: 'active', department: user.department, ouId: user.ouId || null, isVerified: user.isVerified !== false, @@ -121,275 +139,147 @@ export async function createInternalUser(user: Partial) { mustChangePassword: user.mustChangePassword || false, passwordLastChangedAt: new Date().toISOString(), createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString() + updatedAt: new Date().toISOString(), }; if (!IS_DATABASE_OFFLINE) { try { - const pgRecord: any = { - id: record.id, - name: record.name, - email: record.email, - password: record.password, - role: record.role, - status: record.status, - department: record.department, - ouId: record.ouId, - isVerified: record.isVerified, - verificationOtp: record.verificationOtp, - otpExpiry: new Date(record.otpExpiry), - passwordLastChangedAt: new Date(record.passwordLastChangedAt), - createdAt: new Date(record.createdAt), - updatedAt: new Date(record.updatedAt), - permissions: user.permissions || [], - assignedSubsystems: user.assignedSubsystems || [] - }; - return await authDb.user.create({ data: pgRecord }); - } catch (e) { - console.error("[USER-SERVICE] PostgreSQL create user failed:", e); - throw e; + return await authDb.user.create({ + data: { + id: record.id, + name: record.name, + email: record.email, + password: record.password, + role: record.role, + status: record.status, + department: record.department, + ouId: record.ouId, + isVerified: record.isVerified, + verificationOtp: record.verificationOtp, + otpExpiry: new Date(record.otpExpiry), + passwordLastChangedAt: new Date(record.passwordLastChangedAt), + createdAt: new Date(record.createdAt), + updatedAt: new Date(record.updatedAt), + permissions: user.permissions || [], + assignedSubsystems: user.assignedSubsystems || [], + }, + }); + } catch (error) { + console.error('[USER-SERVICE] PostgreSQL create user failed:', error); + throw error; } } - - return await jsonDb.insertRecord('users', record); + + return jsonDb.insertRecord('users', record); } export async function updateInternalUser(id: string, data: any) { - if (data.password && !data.password.startsWith('$2b$')) { - data.password = await bcrypt.hash(data.password, 10); + if (data.password && !isBcryptHash(data.password)) { + data.password = await hashPassword(data.password); } const updatePayload = { ...data, - updatedAt: new Date().toISOString() + updatedAt: new Date().toISOString(), }; if (!IS_DATABASE_OFFLINE) { const pgPayload: any = {}; const allowedFields = [ - 'name', 'email', 'password', 'role', 'status', 'department', 'ouId', - 'isVerified', 'passwordLastChangedAt', 'permissions', - 'assignedSubsystems', 'avatarUrl', 'verificationOtp', 'otpExpiry', 'deletedAt', - 'activeSessionId' + 'name', + 'email', + 'password', + 'role', + 'status', + 'department', + 'ouId', + 'isVerified', + 'passwordLastChangedAt', + 'permissions', + 'assignedSubsystems', + 'avatarUrl', + 'verificationOtp', + 'otpExpiry', + 'deletedAt', + 'activeSessionId', ]; - + for (const key of allowedFields) { - if (key in updatePayload) { - if (['passwordLastChangedAt', 'otpExpiry', 'deletedAt'].includes(key) && updatePayload[key]) { - pgPayload[key] = new Date(updatePayload[key]); - } else { - pgPayload[key] = updatePayload[key]; - } + if (!(key in updatePayload)) continue; + + if ( + ['passwordLastChangedAt', 'otpExpiry', 'deletedAt'].includes(key) && + updatePayload[key] + ) { + pgPayload[key] = new Date(updatePayload[key]); + } else { + pgPayload[key] = updatePayload[key]; } } try { return await authDb.user.update({ where: { id }, - data: pgPayload + data: pgPayload, }); - } catch (e) { - console.error("[USER-SERVICE] PostgreSQL update user failed:", e); - throw e; + } catch (error) { + console.error('[USER-SERVICE] PostgreSQL update user failed:', error); + throw error; } } - return await jsonDb.updateRecord('users', id, updatePayload); + return jsonDb.updateRecord('users', id, updatePayload); } export async function deleteInternalUser(id: string) { if (!IS_DATABASE_OFFLINE) { try { return await authDb.user.delete({ where: { id } }); - } catch (e) { - console.error("[USER-SERVICE] PostgreSQL delete user failed:", e); - throw e; + } catch (error) { + console.error('[USER-SERVICE] PostgreSQL delete user failed:', error); + throw error; } } - return await jsonDb.delete('users', (u: any) => u.id === id); + + return jsonDb.delete('users', (user: any) => user.id === id); } export async function getInternalUserByEmail(email: string): Promise { let dbUser: any = null; + if (!IS_DATABASE_OFFLINE) { try { dbUser = await authDb.user.findUnique({ where: { email } }); - } catch (e) { - console.warn("[USER-SERVICE] DB unreachable during getInternalUserByEmail, checking local store."); + } catch (error) { + console.warn( + '[USER-SERVICE] DB unreachable during getInternalUserByEmail, checking local store.', + ); } } + if (!dbUser) { - dbUser = await jsonDb.findFirst('users', (u: any) => u.email === email); + dbUser = await jsonDb.findFirst('users', (user: any) => user.email === email); } + return dbUser ? omitPassword(dbUser) : null; } export async function getInternalUserById(id: string): Promise { let dbUser: any = null; - if (!IS_DATABASE_OFFLINE) { - try { - dbUser = await authDb.user.findUnique({ where: { id } }); - } catch (e) { - console.warn("[USER-SERVICE] DB unreachable during getInternalUserById, checking local store."); - } - } - if (!dbUser) { - dbUser = await jsonDb.findFirst('users', (u: any) => u.id === id); - } - return dbUser ? omitPassword(dbUser) : null; -} -// Password Reset Requests -export async function getInternalPasswordResetRequests() { if (!IS_DATABASE_OFFLINE) { try { - return await authDb.passwordResetRequest.findMany({ where: { status: 'pending' } }); - } catch (e) {} - } - const all = await jsonDb.getCollection('password_reset_requests'); - return all.filter((r: any) => r.status === 'pending'); -} - -export async function createInternalPasswordResetRequest(userId: string, email: string, name: string) { - const record = { - id: `pwr-${Date.now()}`, - userId, - userEmail: email, - userName: name, - status: 'pending', - createdAt: new Date().toISOString() - }; - - if (!IS_DATABASE_OFFLINE) { - try { - const pgRecord = { - ...record, - createdAt: new Date(record.createdAt) - }; - await authDb.passwordResetRequest.create({ data: pgRecord }); - return; - } catch (e) { - console.error("[USER-SERVICE] PostgreSQL create password reset request failed:", e); - throw e; - } - } - await jsonDb.insertRecord('password_reset_requests', record); -} - -export async function updateInternalPasswordResetRequest(id: string, status: string) { - if (!IS_DATABASE_OFFLINE) { - try { - await authDb.passwordResetRequest.update({ - where: { id }, - data: { status } - }); - return; - } catch (e) { - console.error("[USER-SERVICE] PostgreSQL update password reset request failed:", e); - throw e; - } - } - await jsonDb.updateRecord('password_reset_requests', id, { status }); -} - -// Roles -export async function getInternalRoles() { - if (!IS_DATABASE_OFFLINE) { - try { - const roles = await authDb.role.findMany(); - if (roles && roles.length > 0) { - return roles; - } - } catch (e) { - console.warn("[USER-SERVICE] DB unreachable during getInternalRoles, checking local store."); - } - } - return await jsonDb.getCollection('roles'); -} - -export async function createInternalRole(data: any) { - if (!IS_DATABASE_OFFLINE) { - try { - await authDb.role.create({ data }); - return; - } catch (e) { - console.error("[USER-SERVICE] PostgreSQL create role failed:", e); - throw e; - } - } - await jsonDb.insertRecord('roles', data); -} - -export async function updateInternalRole(id: string, data: any) { - if (!IS_DATABASE_OFFLINE) { - try { - await authDb.role.update({ where: { id }, data }); - return; - } catch (e) { - console.error("[USER-SERVICE] PostgreSQL update role failed:", e); - throw e; - } - } - await jsonDb.updateRecord('roles', id, data); -} - -export async function deleteInternalRole(id: string) { - if (!IS_DATABASE_OFFLINE) { - try { - await authDb.role.delete({ where: { id } }); - return; - } catch (e) { - console.error("[USER-SERVICE] PostgreSQL delete role failed:", e); - throw e; + dbUser = await authDb.user.findUnique({ where: { id } }); + } catch (error) { + console.warn( + '[USER-SERVICE] DB unreachable during getInternalUserById, checking local store.', + ); } } - await jsonDb.delete('roles', (r: any) => r.id === id); -} - -export function generateRandomPassword(length: number = 8): string { - const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; - let password = 'HURC-'; - for (let i = 0; i < length; i++) { - const randomIndex = crypto.randomInt(0, chars.length); - password += chars.charAt(randomIndex); - } - return password; -} - -/** - * PASSWORD UPDATE: Refactored with validation and hashing - */ -export async function updateUserPassword(userId: string, newPassword: string, adminId?: string) { - // 1. Validation - const check = validatePassword(newPassword); - if (!check.isValid) { - throw new Error(check.message); - } - // 2. Hash and Update - const hashedPassword = await bcrypt.hash(newPassword, 10); - const updateData = { - password: hashedPassword, - passwordLastChangedAt: new Date().toISOString(), - mustChangePassword: false, - updatedAt: new Date().toISOString() - }; - - if (!IS_DATABASE_OFFLINE) { - try { - const pgUpdateData = { - password: updateData.password, - passwordLastChangedAt: new Date(updateData.passwordLastChangedAt) - }; - return await authDb.user.update({ - where: { id: userId }, - data: pgUpdateData - }); - } catch (e) { - console.error("[USER-SERVICE] PostgreSQL update user password failed:", e); - throw e; - } + if (!dbUser) { + dbUser = await jsonDb.findFirst('users', (user: any) => user.id === id); } - return await jsonDb.updateRecord('users', userId, updateData); + return dbUser ? omitPassword(dbUser) : null; } diff --git a/src/scripts/benchmark-password-hashing.ts b/src/scripts/benchmark-password-hashing.ts new file mode 100644 index 00000000..316de958 --- /dev/null +++ b/src/scripts/benchmark-password-hashing.ts @@ -0,0 +1,97 @@ +import bcrypt from 'bcryptjs'; +import { performance } from 'node:perf_hooks'; + +const DEFAULT_COSTS = [10, 11, 12, 13]; +const DEFAULT_ITERATIONS = 3; +const TARGET_MAX_MS = Number(process.env.BCRYPT_TARGET_MAX_MS || 750); + +function parseCosts(): number[] { + const configured = process.env.BCRYPT_BENCHMARK_COSTS; + if (!configured) return DEFAULT_COSTS; + + const costs = configured + .split(',') + .map(value => Number(value.trim())) + .filter(value => Number.isInteger(value) && value >= 10 && value <= 15); + + if (costs.length === 0) { + throw new Error('BCRYPT_BENCHMARK_COSTS must contain integers between 10 and 15.'); + } + + return Array.from(new Set(costs)).sort((a, b) => a - b); +} + +function parseIterations(): number { + const iterations = Number(process.env.BCRYPT_BENCHMARK_ITERATIONS || DEFAULT_ITERATIONS); + if (!Number.isInteger(iterations) || iterations < 1 || iterations > 20) { + throw new Error('BCRYPT_BENCHMARK_ITERATIONS must be an integer between 1 and 20.'); + } + return iterations; +} + +function average(values: number[]): number { + return values.reduce((sum, value) => sum + value, 0) / values.length; +} + +async function main() { + const costs = parseCosts(); + const iterations = parseIterations(); + const password = 'HURC-Benchmark-Only!2026'; + const results: Array<{ + cost: number; + averageHashMs: number; + averageVerifyMs: number; + withinTarget: boolean; + }> = []; + + for (const cost of costs) { + const hashTimes: number[] = []; + const verifyTimes: number[] = []; + + for (let index = 0; index < iterations; index += 1) { + const hashStart = performance.now(); + const hash = await bcrypt.hash(password, cost); + hashTimes.push(performance.now() - hashStart); + + const verifyStart = performance.now(); + const valid = await bcrypt.compare(password, hash); + verifyTimes.push(performance.now() - verifyStart); + + if (!valid) throw new Error(`bcrypt verification failed for cost ${cost}.`); + } + + const averageHashMs = average(hashTimes); + const averageVerifyMs = average(verifyTimes); + results.push({ + cost, + averageHashMs: Number(averageHashMs.toFixed(1)), + averageVerifyMs: Number(averageVerifyMs.toFixed(1)), + withinTarget: Math.max(averageHashMs, averageVerifyMs) <= TARGET_MAX_MS, + }); + } + + console.table(results); + + const recommended = results + .filter(result => result.withinTarget) + .sort((a, b) => b.cost - a.cost)[0]; + + if (!recommended) { + console.warn( + `No tested cost completed within the ${TARGET_MAX_MS} ms target. ` + + 'Test a lower target range before changing production configuration.' + ); + process.exitCode = 1; + return; + } + + console.log( + `Recommended BCRYPT_COST for this host: ${recommended.cost} ` + + `(slowest measured operation: ${Math.max(recommended.averageHashMs, recommended.averageVerifyMs)} ms).` + ); +} + +main().catch(error => { + console.error('[bcrypt-benchmark] Failed:', error); + process.exit(1); +});