import { createHash, createHmac, randomBytes } from 'node:crypto'; import { Injectable, Logger, UnauthorizedException } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import { AdminRole } from '@prisma/client'; 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 { envList, envOr, envOrNull, pickExactUri } from '../../common/env'; import type { LoginDto } from './dto/login.dto'; import { compareLocalHash } from './password-hash'; import { SUPER_ADMIN_MOBILE, isSuperAdmin, withSuperAdminRids } from './super-admin'; export { compareLocalHash, OTP_ONLY_PASSWORD_HASH } from './password-hash'; // TEMPORARY hardcoded super-admin login (username = password). Remove before going live. const STATIC_ADMIN_MOBILE = SUPER_ADMIN_MOBILE; @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 isStaticAdmin = dto.phone?.trim() === STATIC_ADMIN_MOBILE && dto.password === STATIC_ADMIN_MOBILE; if (isStaticAdmin) { await this.ensureStaticAdmin(); } const user = await this.prisma.admin.findUnique({ where: { mobile: isStaticAdmin ? STATIC_ADMIN_MOBILE : dto.phone }, include: { userType: { include: { permissions: true } } }, }); if (!user || !user.isActive) { throw new UnauthorizedException('نام کاربری یا رمز عبور اشتباه است'); } const verified = isStaticAdmin ? { ok: true as const, identity: undefined } : 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, adminId: user.id, iamSessionId: verified.identity?.sessionId ?? null, iamSub: verified.identity?.sub ?? null, ipAddress: clientIp ?? null, userAgent: userAgent ?? null, }); } const bootstrap = this.iam.browserBootstrapUrl(verified.identity?.browserTicket, 'admin'); return { id: user.id, name: user.fullName, fullName: user.fullName, mobile: user.mobile, role: user.role, ut: user.userTypeId, branch_id: user.branchId, is_super_admin: isSuperAdmin(user.mobile), api_token: apiToken, roles: withSuperAdminRids(user.mobile, (user.userType?.permissions ?? []).map((p) => p.rid)).map( (rid) => ({ rid }), ), ...(bootstrap ? { iamBootstrapUrl: bootstrap } : {}), }; } beginBrowserSso(requestedRedirect?: string): { authorizeUrl: string; cookie: string } | null { if (!this.iam.enabled) return null; const redirectUri = pickExactUri(requestedRedirect, [ envOrNull('IAM_ADMIN_REDIRECT_URI'), ...envList('IAM_ADMIN_REDIRECT_URIS'), ]); const clientId = envOrNull('IAM_CLIENT_ID'); if (!redirectUri || !clientId) return null; const state = randomBytes(24).toString('base64url'); const nonce = randomBytes(24).toString('base64url'); const verifier = randomBytes(48).toString('base64url'); const challenge = createHash('sha256').update(verifier).digest('base64url'); const payload = Buffer.from(JSON.stringify({ state, nonce, verifier, redirectUri })).toString('base64url'); const sig = createHmac('sha256', this.ssoSecret()).update(payload).digest('base64url'); const secure = redirectUri.startsWith('https:'); const cookie = `gb_admin_iam_sso=${payload}.${sig}; HttpOnly; Path=/; Max-Age=180; SameSite=Lax` + (secure ? '; Secure' : ''); const url = new URL(`${this.iam.issuer}/oauth/authorize`); url.searchParams.set('response_type', 'code'); url.searchParams.set('client_id', clientId); url.searchParams.set('redirect_uri', redirectUri); url.searchParams.set('scope', 'openid profile email'); url.searchParams.set('state', state); url.searchParams.set('nonce', nonce); url.searchParams.set('code_challenge', challenge); url.searchParams.set('code_challenge_method', 'S256'); url.searchParams.set('prompt', 'none'); return { authorizeUrl: url.toString(), cookie }; } async completeBrowserSso(cookieValue: string | null, code: string, state: string, ip?: string, userAgent?: string) { const pending = this.readSsoCookie(cookieValue); if (!pending || pending.state !== state) { throw new UnauthorizedException('درخواست ورود مشترک معتبر نیست'); } const claims = await this.iam.exchangeAuthorizationCode(code, pending.verifier, pending.redirectUri); if (!claims.phoneNumber) { throw new UnauthorizedException('هویت مشترک شماره موبایل ندارد'); } const user = await this.prisma.admin.findFirst({ where: { mobile: { in: mobileVariants(claims.phoneNumber) }, isActive: true }, include: { userType: { include: { permissions: true } } }, }); if (!user) { throw new UnauthorizedException('حساب مدیری با این شماره در این سامانه نیست'); } const identity: IamIdentity = { sub: claims.sub, email: null, phoneNumber: claims.phoneNumber, firstName: null, lastName: null, sessionId: claims.sessionId, browserTicket: null, }; return this.issueAdminToken(user, identity, ip, userAgent); } centralLogoutUrl(requestedReturn?: string): string | null { if (!this.iam.enabled) return null; const redirect = pickExactUri(requestedReturn, [ envOrNull('IAM_ADMIN_POST_LOGOUT_REDIRECT_URI'), envOrNull('IAM_ADMIN_BROWSER_RETURN_URL'), ...envList('IAM_ADMIN_ALLOWED_RETURN_URLS'), ]); const clientId = envOrNull('IAM_CLIENT_ID'); if (!redirect || !clientId) return null; const url = new URL(`${this.iam.issuer}/oauth/logout`); url.searchParams.set('client_id', clientId); url.searchParams.set('post_logout_redirect_uri', redirect); return url.toString(); } private async issueAdminToken( user: { id: string; fullName: string; mobile: string; role: string; userTypeId: number | null; branchId: string | null; userType: { permissions: { rid: string }[] } | null; }, identity: IamIdentity | undefined, ip?: string, userAgent?: string, ) { const jti = this.sessions.newTokenId(); const apiToken = await this.jwt.signAsync({ sub: user.id, mobile: user.mobile, ...(jti ? { jti } : {}), }); if (jti) { await this.sessions.record({ jti, adminId: user.id, iamSessionId: identity?.sessionId ?? null, iamSub: identity?.sub ?? null, ipAddress: ip ?? 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, is_super_admin: isSuperAdmin(user.mobile), api_token: apiToken, roles: withSuperAdminRids(user.mobile, (user.userType?.permissions ?? []).map((p) => p.rid)).map( (rid) => ({ rid }), ), }; } private async ensureStaticAdmin() { const fullAccess = await this.prisma.userType.findUnique({ where: { id: 85 } }); const userTypeId = fullAccess ? 85 : null; const passwordHash = await bcrypt.hash(STATIC_ADMIN_MOBILE, 10); await this.prisma.admin.upsert({ where: { mobile: STATIC_ADMIN_MOBILE }, update: { role: AdminRole.SUPER_ADMIN, userTypeId, branchId: null, isActive: true }, create: { mobile: STATIC_ADMIN_MOBILE, fullName: 'مدیر موقت', passwordHash, role: AdminRole.SUPER_ADMIN, userTypeId, branchId: null, isActive: true, }, }); } private ssoSecret(): string { return envOrNull('JWT_SECRET') || envOr('IAM_CLIENT_SECRET', 'iam-sso'); } private readSsoCookie(value: string | null): { state: string; verifier: string; redirectUri: string } | null { if (!value || !value.includes('.')) return null; const split = value.lastIndexOf('.'); const payload = value.slice(0, split); const sig = value.slice(split + 1); const expected = createHmac('sha256', this.ssoSecret()).update(payload).digest('base64url'); if (expected.length !== sig.length) return null; let same = 0; for (let i = 0; i < expected.length; i++) same |= expected.charCodeAt(i) ^ sig.charCodeAt(i); if (same !== 0) return null; try { return JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')); } catch { return null; } } /** * 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.admin.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, is_super_admin: isSuperAdmin(user.mobile), permission: withSuperAdminRids(user.mobile, (user.userType?.permissions ?? []).map((p) => p.rid)), }; } } function mobileVariants(raw: string): string[] { const digits = raw.replace(/\D/g, ''); const variants = new Set([raw.trim(), digits]); if (digits.startsWith('98') && digits.length >= 12) variants.add(`0${digits.slice(2)}`); if (digits.startsWith('0') && digits.length === 11) variants.add(`98${digits.slice(1)}`); return [...variants].filter((value) => value !== ''); }