Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
62 changes: 62 additions & 0 deletions src/lib/security/password-hashing.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
return bcrypt.hash(password, getPasswordHashCost());
}

export async function verifyPassword(password: string, encodedHash: unknown): Promise<boolean> {
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();
}
117 changes: 117 additions & 0 deletions src/lib/services/user-access-service.ts
Original file line number Diff line number Diff line change
@@ -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<any>('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<any>('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<any>('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<any>('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<any>('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<any>('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);
}
72 changes: 72 additions & 0 deletions src/lib/services/user-password-service.ts
Original file line number Diff line number Diff line change
@@ -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<any>('users', userId, updateData);
}
Loading
Loading