import { Injectable } from '@nestjs/common'; import type { CurrentAdmin } from 'adminjs'; import { Role } from '@/common/enums/roles.enum'; import { UsersService } from '@/modules/users/providers/users.service'; import { HashingProvider } from '@/common/modules/hashing/providers/hashing.provider'; import { RedisService } from '@/common/modules/redis/providers/redis.service'; const MAX_FAILED_ATTEMPTS = 5; const ATTEMPT_WINDOW_SECONDS = 15 * 60; /** * Hash of a random throwaway string. Comparing against it when the phone * number is unknown keeps the response time identical to the success path, * so attackers cannot enumerate superuser phone numbers via timing. */ const DUMMY_BCRYPT_HASH = '$2b$10$ZFUmcSvMhrye2p.Dt1J7rOcfEdi.NcDLk3Bf55taT3ed57cAcpiNS'; @Injectable() export class AdminPanelAuthenticator { constructor( /** * Inject Users Service */ private readonly usersService: UsersService, /** * Inject Hashing Provider */ private readonly hashingProvider: HashingProvider, /** * Inject Redis Service */ private readonly redisService: RedisService, ) {} public async authenticate( phone: string, password: string, ): Promise { const normalizedPhone = phone?.trim(); if (!normalizedPhone || !password) { return null; } const attemptsKey = `admin:panel:login:${normalizedPhone}`; const failedAttempts = (await this.redisService.get(attemptsKey)) ?? 0; if (failedAttempts >= MAX_FAILED_ATTEMPTS) { return null; } const user = await this.usersService.findOneByPhone(normalizedPhone); const isSuperuser = !!user && user.role === Role.SUPERUSER; const passwordMatches = await this.hashingProvider.compare( password, isSuperuser && user.password ? user.password : DUMMY_BCRYPT_HASH, ); if (!isSuperuser || !user.password || !passwordMatches) { await this.redisService.set( attemptsKey, failedAttempts + 1, ATTEMPT_WINDOW_SECONDS, ); return null; } await this.redisService.del(attemptsKey); return { id: user.id.toString(), firstName: user.firstName, lastName: user.lastName, phone: user.phone, role: user.role, // AdminJS requires an email on CurrentAdmin for the UI email: user.phone, }; } }