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>
195 lines
7.3 KiB
TypeScript
195 lines
7.3 KiB
TypeScript
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,
|
|
IamMfaRequiredError,
|
|
IamService,
|
|
type IamIdentity,
|
|
} from '../iam/iam.service';
|
|
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);
|
|
|
|
constructor(
|
|
private readonly prisma: PrismaService,
|
|
private readonly jwt: JwtService,
|
|
private readonly iam: IamService,
|
|
private readonly sessions: AdminSessionService,
|
|
) {}
|
|
|
|
/**
|
|
* Port of legacy AdminController::login (henza_server).
|
|
*
|
|
* Legacy issued a random 60-char string as `api_token` and persisted it on the
|
|
* user row; this issues a signed JWT instead. The response keeps the legacy
|
|
* shape the admin panel's auth store reads:
|
|
* - `api_token` -> stored in localStorage under token/branch_token
|
|
* - `roles` -> [{ rid }], mapped to rids by the frontend then expanded
|
|
* through checkParent()
|
|
*
|
|
* Deliberate deviation from legacy: a bad phone and a bad password both return
|
|
* 401 here. Legacy returned 404 (firstOrFail) for an unknown phone and 422 for
|
|
* a wrong password, which let anyone enumerate valid admin phone numbers.
|
|
*/
|
|
async login(dto: LoginDto, clientIp?: string, userAgent?: string) {
|
|
const user = await this.prisma.adminUser.findUnique({
|
|
where: { mobile: dto.phone },
|
|
include: { userType: { include: { permissions: true } } },
|
|
});
|
|
|
|
if (!user || !user.isActive) {
|
|
throw new UnauthorizedException('نام کاربری یا رمز عبور اشتباه است');
|
|
}
|
|
|
|
const verified = await this.verifyPassword(dto, user.passwordHash, clientIp);
|
|
if (!verified.ok) {
|
|
throw new UnauthorizedException('نام کاربری یا رمز عبور اشتباه است');
|
|
}
|
|
|
|
// While session tracking is off this is null and the token is signed exactly
|
|
// as before — same payload, same claims.
|
|
const jti = this.sessions.newTokenId();
|
|
const apiToken = await this.jwt.signAsync({
|
|
sub: user.id,
|
|
mobile: user.mobile,
|
|
...(jti ? { jti } : {}),
|
|
});
|
|
|
|
if (jti) {
|
|
// Recorded so a central sign-out (or an admin revoking the session) can end
|
|
// this token before it expires — see AdminSessionService.
|
|
await this.sessions.record({
|
|
jti,
|
|
adminUserId: user.id,
|
|
iamSessionId: verified.identity?.sessionId ?? null,
|
|
iamSub: verified.identity?.sub ?? null,
|
|
ipAddress: clientIp ?? null,
|
|
userAgent: userAgent ?? null,
|
|
});
|
|
}
|
|
|
|
return {
|
|
id: user.id,
|
|
name: user.fullName,
|
|
fullName: user.fullName,
|
|
mobile: user.mobile,
|
|
role: user.role,
|
|
ut: user.userTypeId,
|
|
branch_id: user.branchId,
|
|
api_token: apiToken,
|
|
roles: (user.userType?.permissions ?? []).map((p) => ({ rid: p.rid })),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Password check, optionally delegated to the central identity service.
|
|
*
|
|
* With `IAM_ENABLED=false` (the default) this is exactly the previous
|
|
* behaviour: a single bcrypt compare. With it on, the identity service is
|
|
* asked first and the local hash is only consulted when that check could not
|
|
* give a definite answer — so an IAM outage never blocks admin login.
|
|
*/
|
|
private async verifyPassword(
|
|
dto: LoginDto,
|
|
passwordHash: string,
|
|
clientIp?: string,
|
|
): Promise<{ ok: boolean; identity?: IamIdentity }> {
|
|
if (!this.iam.enabled) {
|
|
return { ok: await compareLocalHash(dto.password, passwordHash) };
|
|
}
|
|
|
|
try {
|
|
const identity = await this.iam.verifyCredentials(dto.phone, dto.password, clientIp);
|
|
|
|
// The IAM session id travels with the recorded session so a back-channel
|
|
// logout can find and end exactly this session.
|
|
return { ok: true, identity };
|
|
} catch (error) {
|
|
if (error instanceof IamMfaRequiredError) {
|
|
// Nowhere to collect the code on this UI-less path; say so plainly
|
|
// instead of silently falling back to a password the IAM may have retired.
|
|
throw new UnauthorizedException(
|
|
'برای این حساب احراز هویت دو مرحلهای فعال است و از این مسیر قابل تکمیل نیست',
|
|
);
|
|
}
|
|
|
|
if (error instanceof IamInvalidCredentialsError && !this.iam.allowsLocalFallback) {
|
|
return { ok: false };
|
|
}
|
|
|
|
if (!(error instanceof IamInvalidCredentialsError)) {
|
|
this.logger.warn(
|
|
`falling back to the local password check: ${(error as Error).message}`,
|
|
);
|
|
if (!this.iam.allowsLocalFallback) {
|
|
throw new UnauthorizedException('ورود در حال حاضر ممکن نیست؛ سرویس هویت در دسترس نیست');
|
|
}
|
|
}
|
|
|
|
// fallback mode: the account may not have been migrated to the IAM yet
|
|
return { ok: await compareLocalHash(dto.password, passwordHash) };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Port of legacy UserController::showData, which the router guard calls on every
|
|
* cold load to refresh the permission list:
|
|
*
|
|
* $user['permission'] = Permission::where('ut',$user->ut)->select('rid')->get()->pluck('rid');
|
|
*
|
|
* Returns raw (unexpanded) rids — the frontend runs checkParent() on them, as in
|
|
* legacy. The guard's own checks use the pre-expanded list from JwtStrategy.
|
|
*/
|
|
async showData(adminId: string) {
|
|
const user = await this.prisma.adminUser.findUnique({
|
|
where: { id: adminId },
|
|
include: { userType: { include: { permissions: true } } },
|
|
});
|
|
|
|
if (!user) {
|
|
throw new UnauthorizedException();
|
|
}
|
|
|
|
return {
|
|
id: user.id,
|
|
name: user.fullName,
|
|
fullName: user.fullName,
|
|
mobile: user.mobile,
|
|
role: user.role,
|
|
ut: user.userTypeId,
|
|
branch_id: user.branchId,
|
|
user_type: user.userType ? { id: user.userType.id, title: user.userType.title } : null,
|
|
permission: (user.userType?.permissions ?? []).map((p) => p.rid),
|
|
};
|
|
}
|
|
}
|