feat(auth): refuse non-bcrypt password hashes before calling bcrypt
compareLocalHash() returns false for any passwordHash that does not start with `$2`: '' (accounts created without a password) and the new OTP_ONLY_PASSWORD_HASH sentinel the data import writes for ~1,590 OTP-only legacy customers. It is used on both local-compare paths of verifyPassword(), IAM off and IAM fallback. bcryptjs 3 already returns false for these. The guard keeps the result from depending on that: a future version or a native binding that throws would turn a wrong password into a 500 (owner ruling 2026-10-07). Behaviour-neutral for existing data. Real hashes start with $2a$, $2b$ or PHP's $2y$, and every row that fails the prefix already failed in bcrypt. Verified live: wrong password 401, correct password 200. Tests: refusal for both values with IAM off and on, bcrypt never called for them, and legacy $2y$ hashes still verify. 21/21. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
e020e6a633
commit
884413513d
@@ -11,6 +11,30 @@ import {
|
||||
import { AdminSessionService } from '../sessions/admin-session.service';
|
||||
import type { LoginDto } from './dto/login.dto';
|
||||
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
private readonly logger = new Logger(AuthService.name);
|
||||
@@ -100,7 +124,7 @@ export class AuthService {
|
||||
clientIp?: string,
|
||||
): Promise<{ ok: boolean; identity?: IamIdentity }> {
|
||||
if (!this.iam.enabled) {
|
||||
return { ok: await bcrypt.compare(dto.password, passwordHash) };
|
||||
return { ok: await compareLocalHash(dto.password, passwordHash) };
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -132,7 +156,7 @@ export class AuthService {
|
||||
}
|
||||
|
||||
// fallback mode: the account may not have been migrated to the IAM yet
|
||||
return { ok: await bcrypt.compare(dto.password, passwordHash) };
|
||||
return { ok: await compareLocalHash(dto.password, passwordHash) };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user