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), }; } }