From 7019cad9322ab52880d0dd0ac0624aa1866e6f92 Mon Sep 17 00:00:00 2001 From: "s.esmaeili" Date: Wed, 7 Oct 2026 19:35:02 +0330 Subject: [PATCH] refactor(auth): move the password-hash helpers into their own module compareLocalHash, OTP_ONLY_PASSWORD_HASH and a new isPasswordHash now live in password-hash.ts, which depends on bcryptjs only. The data-import script needs them, and importing auth.service would load NestJS and the ESM-only @nestjs/jwt. auth.service re-exports the first two, so callers and tests are unchanged. Its own bcrypt import, now unused, is dropped. No behaviour change: 21/21 auth tests, 178/178 overall. Co-Authored-By: Claude Opus 5.5 --- apps/api/src/modules/auth/auth.service.ts | 26 ++--------------- apps/api/src/modules/auth/password-hash.ts | 33 ++++++++++++++++++++++ 2 files changed, 35 insertions(+), 24 deletions(-) create mode 100644 apps/api/src/modules/auth/password-hash.ts diff --git a/apps/api/src/modules/auth/auth.service.ts b/apps/api/src/modules/auth/auth.service.ts index 5006a4f..a074c63 100644 --- a/apps/api/src/modules/auth/auth.service.ts +++ b/apps/api/src/modules/auth/auth.service.ts @@ -1,6 +1,5 @@ import { Injectable, Logger, UnauthorizedException } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; -import * as bcrypt from 'bcryptjs'; import { PrismaService } from '../../prisma/prisma.service'; import { IamInvalidCredentialsError, @@ -10,30 +9,9 @@ import { } from '../iam/iam.service'; import { AdminSessionService } from '../sessions/admin-session.service'; import type { LoginDto } from './dto/login.dto'; +import { compareLocalHash } from './password-hash'; -/** - * Value written into `passwordHash` for accounts that must never log in with a - * password — the ~1,590 legacy customers who only ever used OTP - * (docs/DATA-MIGRATION-MAP.md §10.4). The `!` prefix is the Django/Rails - * "unusable password" convention: readable by anyone looking at the row, and - * matched by no password. - */ -export const OTP_ONLY_PASSWORD_HASH = '!OTP_ONLY_NO_PASSWORD_LOGIN'; - -/** - * A real bcrypt hash always starts with `$2` ($2a$, $2b$, or PHP's $2y$ from - * the legacy server — all three verify with bcryptjs). Anything else is not a - * password: '' on accounts created without one, or OTP_ONLY_PASSWORD_HASH. - * - * Such hashes are refused here, BEFORE bcrypt is called, so the outcome never - * depends on how a particular bcrypt build treats a malformed hash. bcryptjs 3 - * happens to return false today; a future version or a native binding might - * throw and turn a wrong password into a 500 (owner ruling 2026-10-07). - */ -export async function compareLocalHash(password: string, passwordHash: string | null | undefined) { - if (typeof passwordHash !== 'string' || !passwordHash.startsWith('$2')) return false; - return bcrypt.compare(password, passwordHash); -} +export { compareLocalHash, OTP_ONLY_PASSWORD_HASH } from './password-hash'; @Injectable() export class AuthService { diff --git a/apps/api/src/modules/auth/password-hash.ts b/apps/api/src/modules/auth/password-hash.ts new file mode 100644 index 0000000..eaf515e --- /dev/null +++ b/apps/api/src/modules/auth/password-hash.ts @@ -0,0 +1,33 @@ +import * as bcrypt from 'bcryptjs'; + +/** + * Value written into `passwordHash` for accounts that must never log in with a + * password — the ~1,590 legacy customers who only ever used OTP + * (docs/DATA-MIGRATION-MAP.md §10.4). The `!` prefix is the Django/Rails + * "unusable password" convention: readable by anyone looking at the row, and + * matched by no password. + * + * Kept in this dependency-free module (bcryptjs only) so the data-import + * script can use it without loading NestJS. + */ +export const OTP_ONLY_PASSWORD_HASH = '!OTP_ONLY_NO_PASSWORD_LOGIN'; + +/** + * A real bcrypt hash always starts with `$2` ($2a$, $2b$, or PHP's $2y$ from + * the legacy server — all three verify with bcryptjs). Anything else is not a + * password: '' on accounts created without one, or OTP_ONLY_PASSWORD_HASH. + * + * Such hashes are refused here, BEFORE bcrypt is called, so the outcome never + * depends on how a particular bcrypt build treats a malformed hash. bcryptjs 3 + * happens to return false today; a future version or a native binding might + * throw and turn a wrong password into a 500 (owner ruling 2026-10-07). + */ +export async function compareLocalHash(password: string, passwordHash: string | null | undefined) { + if (typeof passwordHash !== 'string' || !passwordHash.startsWith('$2')) return false; + return bcrypt.compare(password, passwordHash); +} + +/** True when a stored legacy hash can be carried over as a working password. */ +export function isPasswordHash(hash: unknown): hash is string { + return typeof hash === 'string' && hash.startsWith('$2'); +}