34 lines
1.5 KiB
TypeScript
34 lines
1.5 KiB
TypeScript
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');
|
||
|
|
}
|