87 lines
2.3 KiB
TypeScript
87 lines
2.3 KiB
TypeScript
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<CurrentAdmin | null> {
|
|
const normalizedPhone = phone?.trim();
|
|
|
|
if (!normalizedPhone || !password) {
|
|
return null;
|
|
}
|
|
|
|
const attemptsKey = `admin:panel:login:${normalizedPhone}`;
|
|
const failedAttempts =
|
|
(await this.redisService.get<number>(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,
|
|
};
|
|
}
|
|
}
|