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 <noreply@anthropic.com>
This commit is contained in:
s.esmaeili
2026-10-07 19:35:02 +03:30
co-authored by Claude Opus 5.5
parent 3ac9e92e9f
commit 7019cad932
2 changed files with 35 additions and 24 deletions
+2 -24
View File
@@ -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 {
@@ -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');
}