527 lines
15 KiB
TypeScript
527 lines
15 KiB
TypeScript
import {
|
|
BadRequestException,
|
|
ConflictException,
|
|
HttpException,
|
|
HttpStatus,
|
|
Injectable,
|
|
Logger,
|
|
UnauthorizedException,
|
|
} from '@nestjs/common';
|
|
import { ConfigService } from '@nestjs/config';
|
|
import { JwtService } from '@nestjs/jwt';
|
|
import { InjectRepository } from '@nestjs/typeorm';
|
|
import * as bcrypt from 'bcrypt';
|
|
import { Repository, IsNull } from 'typeorm';
|
|
import { Customer } from '../customers/entities/customer.entity';
|
|
import { Salon } from '../salons/entities/salon.entity';
|
|
import { ProvincesService } from '../provinces/provinces.service';
|
|
import { SmsService } from '../sms/sms.service';
|
|
import { SalonSubscription } from '../subscriptions/entities/salon-subscription.entity';
|
|
import { SalonSubscriptionsService } from '../subscriptions/salon-subscriptions.service';
|
|
import { UserRole } from '../users/enums/user-role.enum';
|
|
import { User } from '../users/entities/user.entity';
|
|
import { UsersService } from '../users/users.service';
|
|
import { VerifyAdminOtpDto } from './dto/verify-admin-otp.dto';
|
|
import { VerifyOtpDto } from './dto/verify-otp.dto';
|
|
import { VerifySalonOtpDto } from './dto/verify-salon-otp.dto';
|
|
import { Otp } from './entities/otp.entity';
|
|
import { RefreshToken } from './entities/refresh-token.entity';
|
|
import { AuthUser } from './interfaces/auth-user.interface';
|
|
import { JwtPayload } from './interfaces/jwt-payload.interface';
|
|
|
|
export interface AuthTokensResponse {
|
|
accessToken: string;
|
|
refreshToken: string;
|
|
user: {
|
|
id: string;
|
|
firstName: string;
|
|
lastName: string;
|
|
mobile: string;
|
|
role: UserRole;
|
|
};
|
|
}
|
|
|
|
export interface SalonAuthResponse extends AuthTokensResponse {
|
|
salon?: Salon;
|
|
}
|
|
|
|
export interface MeResponse {
|
|
user: AuthTokensResponse['user'];
|
|
salons?: Salon[];
|
|
activeSubscription?: SalonSubscription | null;
|
|
}
|
|
|
|
@Injectable()
|
|
export class AuthService {
|
|
private readonly logger = new Logger(AuthService.name);
|
|
private readonly otpLength: number;
|
|
private readonly otpExpiresInMinutes: number;
|
|
private readonly otpSalonSignupExpiresInMinutes: number;
|
|
private readonly otpMaxAttempts: number;
|
|
private readonly otpCooldownSeconds: number;
|
|
private readonly refreshExpiresIn: string;
|
|
|
|
constructor(
|
|
@InjectRepository(Otp)
|
|
private readonly otpRepository: Repository<Otp>,
|
|
@InjectRepository(RefreshToken)
|
|
private readonly refreshTokenRepository: Repository<RefreshToken>,
|
|
@InjectRepository(Customer)
|
|
private readonly customersRepository: Repository<Customer>,
|
|
@InjectRepository(Salon)
|
|
private readonly salonsRepository: Repository<Salon>,
|
|
private readonly provincesService: ProvincesService,
|
|
private readonly usersService: UsersService,
|
|
private readonly salonSubscriptionsService: SalonSubscriptionsService,
|
|
private readonly smsService: SmsService,
|
|
private readonly jwtService: JwtService,
|
|
private readonly configService: ConfigService,
|
|
) {
|
|
this.otpLength = parseInt(
|
|
this.configService.get<string>('OTP_LENGTH') ?? '5',
|
|
10,
|
|
);
|
|
this.otpExpiresInMinutes = parseInt(
|
|
this.configService.get<string>('OTP_EXPIRES_IN_MINUTES') ?? '5',
|
|
10,
|
|
);
|
|
this.otpSalonSignupExpiresInMinutes = parseInt(
|
|
this.configService.get<string>('OTP_SALON_SIGNUP_EXPIRES_IN_MINUTES') ??
|
|
'15',
|
|
10,
|
|
);
|
|
this.otpMaxAttempts = parseInt(
|
|
this.configService.get<string>('OTP_MAX_ATTEMPTS') ?? '5',
|
|
10,
|
|
);
|
|
this.otpCooldownSeconds = parseInt(
|
|
this.configService.get<string>('OTP_COOLDOWN_SECONDS') ?? '60',
|
|
10,
|
|
);
|
|
this.refreshExpiresIn =
|
|
this.configService.get<string>('JWT_REFRESH_EXPIRES_IN') ?? '7d';
|
|
}
|
|
|
|
async requestOtp(mobile: string): Promise<{ message: string; code?: string }> {
|
|
const code = await this.sendOtp(mobile, this.otpExpiresInMinutes);
|
|
return this.buildOtpRequestResponse(code);
|
|
}
|
|
|
|
async requestAdminOtp(
|
|
mobile: string,
|
|
): Promise<{ message: string; code?: string }> {
|
|
await this.ensureAdminMobile(mobile);
|
|
return this.requestOtp(mobile);
|
|
}
|
|
|
|
async checkSalonMobile(
|
|
mobile: string,
|
|
): Promise<{ isRegistered: boolean }> {
|
|
await this.ensureSalonMobileCanProceed(mobile);
|
|
const isRegistered = await this.isSalonOwnerRegistered(mobile);
|
|
return { isRegistered };
|
|
}
|
|
|
|
async requestSalonOtp(mobile: string): Promise<{
|
|
message: string;
|
|
isRegistered: boolean;
|
|
expiresInSeconds: number;
|
|
code?: string;
|
|
}> {
|
|
await this.ensureSalonMobileCanProceed(mobile);
|
|
const isRegistered = await this.isSalonOwnerRegistered(mobile);
|
|
const expiresInMinutes = isRegistered
|
|
? this.otpExpiresInMinutes
|
|
: this.otpSalonSignupExpiresInMinutes;
|
|
|
|
const code = await this.sendOtp(mobile, expiresInMinutes);
|
|
|
|
return {
|
|
...this.buildOtpRequestResponse(code),
|
|
isRegistered,
|
|
expiresInSeconds: expiresInMinutes * 60,
|
|
};
|
|
}
|
|
|
|
async verifySalonOtp(dto: VerifySalonOtpDto): Promise<SalonAuthResponse> {
|
|
await this.consumeOtp(dto.mobile, dto.code);
|
|
|
|
const existingUser = await this.usersService.findByMobile(dto.mobile);
|
|
|
|
if (existingUser) {
|
|
if (existingUser.role !== UserRole.SALON) {
|
|
throw new ConflictException(
|
|
'This mobile is already registered with another account type',
|
|
);
|
|
}
|
|
|
|
return this.issueTokens(existingUser);
|
|
}
|
|
|
|
return this.registerSalonOwner(dto);
|
|
}
|
|
|
|
private async sendOtp(
|
|
mobile: string,
|
|
expiresInMinutes: number,
|
|
): Promise<string> {
|
|
const recentOtp = await this.otpRepository.findOne({
|
|
where: { mobile, verified: false },
|
|
order: { createdAt: 'DESC' },
|
|
});
|
|
|
|
if (recentOtp) {
|
|
const cooldownEnd =
|
|
recentOtp.createdAt.getTime() + this.otpCooldownSeconds * 1000;
|
|
if (Date.now() < cooldownEnd) {
|
|
throw new HttpException(
|
|
'Please wait before requesting a new OTP',
|
|
HttpStatus.TOO_MANY_REQUESTS,
|
|
);
|
|
}
|
|
}
|
|
|
|
await this.otpRepository.delete({ mobile, verified: false });
|
|
|
|
const code = this.generateOtpCode();
|
|
const hashedCode = await bcrypt.hash(code, 10);
|
|
const expiresAt = new Date(Date.now() + expiresInMinutes * 60 * 1000);
|
|
|
|
await this.otpRepository.save({
|
|
mobile,
|
|
code: hashedCode,
|
|
expiresAt,
|
|
});
|
|
|
|
const sent = await this.smsService.sendSms(mobile, `کد ورود: ${code}`);
|
|
|
|
if (!sent && !this.shouldExposeOtpInResponse()) {
|
|
throw new BadRequestException('Failed to send OTP');
|
|
}
|
|
|
|
if (this.shouldExposeOtpInResponse()) {
|
|
this.logger.debug(`OTP for ${mobile}: ${code}`);
|
|
}
|
|
|
|
return code;
|
|
}
|
|
|
|
private shouldExposeOtpInResponse(): boolean {
|
|
return this.configService.get<string>('NODE_ENV') !== 'production';
|
|
}
|
|
|
|
private buildOtpRequestResponse(code: string): {
|
|
message: string;
|
|
code?: string;
|
|
} {
|
|
return {
|
|
message: 'OTP sent',
|
|
...(this.shouldExposeOtpInResponse() ? { code } : {}),
|
|
};
|
|
}
|
|
|
|
private async consumeOtp(mobile: string, code: string): Promise<void> {
|
|
const otp = await this.otpRepository.findOne({
|
|
where: { mobile, verified: false },
|
|
order: { createdAt: 'DESC' },
|
|
});
|
|
|
|
if (!otp) {
|
|
throw new UnauthorizedException('Invalid or expired OTP');
|
|
}
|
|
|
|
if (otp.expiresAt < new Date()) {
|
|
throw new UnauthorizedException('Invalid or expired OTP');
|
|
}
|
|
|
|
if (otp.attempts >= this.otpMaxAttempts) {
|
|
throw new UnauthorizedException('Too many failed attempts');
|
|
}
|
|
|
|
const isValid = await bcrypt.compare(code, otp.code);
|
|
if (!isValid) {
|
|
otp.attempts += 1;
|
|
await this.otpRepository.save(otp);
|
|
throw new UnauthorizedException('Invalid or expired OTP');
|
|
}
|
|
|
|
otp.verified = true;
|
|
await this.otpRepository.save(otp);
|
|
}
|
|
|
|
private async isSalonOwnerRegistered(mobile: string): Promise<boolean> {
|
|
const user = await this.usersService.findByMobile(mobile);
|
|
return user?.role === UserRole.SALON;
|
|
}
|
|
|
|
private async ensureSalonMobileCanProceed(mobile: string): Promise<void> {
|
|
const user = await this.usersService.findByMobile(mobile);
|
|
|
|
if (user && user.role !== UserRole.SALON) {
|
|
throw new ConflictException(
|
|
'This mobile is already registered with another account type',
|
|
);
|
|
}
|
|
}
|
|
|
|
private async ensureAdminMobile(mobile: string): Promise<void> {
|
|
const user = await this.usersService.findByMobile(mobile);
|
|
|
|
if (!user || user.role !== UserRole.ADMIN) {
|
|
throw new BadRequestException('این شماره ادمین نیست');
|
|
}
|
|
}
|
|
|
|
private async registerSalonOwner(
|
|
dto: VerifySalonOtpDto,
|
|
): Promise<SalonAuthResponse> {
|
|
this.validateSalonSignupFields(dto);
|
|
await this.validateSalonLocation(dto.salonProvinceId!, dto.salonCityId!);
|
|
|
|
const user = await this.usersService.create({
|
|
firstName: dto.firstName!,
|
|
lastName: dto.lastName!,
|
|
mobile: dto.mobile,
|
|
role: UserRole.SALON,
|
|
});
|
|
|
|
const salon = await this.salonsRepository.save({
|
|
name: dto.salonName!,
|
|
description: dto.salonDescription ?? null,
|
|
address: dto.salonAddress!,
|
|
phone: dto.salonPhone!,
|
|
provinceId: dto.salonProvinceId!,
|
|
cityId: dto.salonCityId!,
|
|
ownerId: user.id,
|
|
});
|
|
|
|
const tokens = await this.issueTokens(user);
|
|
return { ...tokens, salon };
|
|
}
|
|
|
|
private validateSalonSignupFields(dto: VerifySalonOtpDto): void {
|
|
const requiredFields: Array<keyof VerifySalonOtpDto> = [
|
|
'firstName',
|
|
'lastName',
|
|
'salonName',
|
|
'salonAddress',
|
|
'salonPhone',
|
|
'salonProvinceId',
|
|
'salonCityId',
|
|
];
|
|
|
|
const missingFields = requiredFields.filter((field) => !dto[field]);
|
|
|
|
if (missingFields.length > 0) {
|
|
throw new BadRequestException(
|
|
`Missing required fields for salon registration: ${missingFields.join(', ')}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
private async validateSalonLocation(
|
|
provinceId: string,
|
|
cityId: string,
|
|
): Promise<void> {
|
|
const city = await this.provincesService.findCity(provinceId, cityId);
|
|
if (!city) {
|
|
throw new BadRequestException('شهر انتخابشده با استان مطابقت ندارد');
|
|
}
|
|
}
|
|
|
|
async verifyOtp(dto: VerifyOtpDto): Promise<AuthTokensResponse> {
|
|
await this.consumeOtp(dto.mobile, dto.code);
|
|
|
|
const user = await this.resolveUserForLogin(dto);
|
|
return this.issueTokens(user);
|
|
}
|
|
|
|
async verifyAdminOtp(dto: VerifyAdminOtpDto): Promise<AuthTokensResponse> {
|
|
await this.ensureAdminMobile(dto.mobile);
|
|
await this.consumeOtp(dto.mobile, dto.code);
|
|
|
|
const user = await this.usersService.findByMobile(dto.mobile);
|
|
if (!user || user.role !== UserRole.ADMIN) {
|
|
throw new BadRequestException('این شماره ادمین نیست');
|
|
}
|
|
|
|
return this.issueTokens(user);
|
|
}
|
|
|
|
async refresh(refreshToken: string): Promise<AuthTokensResponse> {
|
|
const storedTokens = await this.refreshTokenRepository.find({
|
|
where: { revokedAt: IsNull() },
|
|
relations: { user: true },
|
|
});
|
|
|
|
let matchedToken: RefreshToken | null = null;
|
|
for (const stored of storedTokens) {
|
|
if (await bcrypt.compare(refreshToken, stored.tokenHash)) {
|
|
if (stored.expiresAt < new Date()) {
|
|
throw new UnauthorizedException('Invalid refresh token');
|
|
}
|
|
matchedToken = stored;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (!matchedToken) {
|
|
throw new UnauthorizedException('Invalid refresh token');
|
|
}
|
|
|
|
matchedToken.revokedAt = new Date();
|
|
await this.refreshTokenRepository.save(matchedToken);
|
|
|
|
return this.issueTokens(matchedToken.user);
|
|
}
|
|
|
|
async logout(userId: string, refreshToken: string): Promise<void> {
|
|
const storedTokens = await this.refreshTokenRepository.find({
|
|
where: { userId, revokedAt: IsNull() },
|
|
});
|
|
|
|
for (const stored of storedTokens) {
|
|
if (await bcrypt.compare(refreshToken, stored.tokenHash)) {
|
|
stored.revokedAt = new Date();
|
|
await this.refreshTokenRepository.save(stored);
|
|
return;
|
|
}
|
|
}
|
|
|
|
throw new UnauthorizedException('Invalid refresh token');
|
|
}
|
|
|
|
async getMe(authUser: AuthUser): Promise<MeResponse> {
|
|
const user = await this.usersService.findOne(authUser.id);
|
|
|
|
const response: MeResponse = {
|
|
user: {
|
|
id: user.id,
|
|
firstName: user.firstName,
|
|
lastName: user.lastName,
|
|
mobile: user.mobile,
|
|
role: user.role,
|
|
},
|
|
};
|
|
|
|
if (user.role === UserRole.SALON) {
|
|
response.salons = await this.salonsRepository.find({
|
|
where: { ownerId: user.id },
|
|
relations: { province: true, city: true },
|
|
order: { createdAt: 'ASC' },
|
|
});
|
|
response.activeSubscription =
|
|
await this.salonSubscriptionsService.findActiveSubscriptionForOwner(
|
|
user.id,
|
|
);
|
|
}
|
|
|
|
return response;
|
|
}
|
|
|
|
private async resolveUserForLogin(dto: VerifyOtpDto): Promise<User> {
|
|
const existingUser = await this.usersService.findByMobile(dto.mobile);
|
|
|
|
if (existingUser) {
|
|
return existingUser;
|
|
}
|
|
|
|
if (!dto.role) {
|
|
throw new BadRequestException(
|
|
'role is required when registering a new account',
|
|
);
|
|
}
|
|
|
|
return this.createUserFromOtp(dto);
|
|
}
|
|
|
|
private async createUserFromOtp(dto: VerifyOtpDto): Promise<User> {
|
|
const firstName = dto.firstName ?? 'کاربر';
|
|
const lastName = dto.lastName ?? dto.mobile.slice(-4);
|
|
|
|
const user = await this.usersService.create({
|
|
firstName,
|
|
lastName,
|
|
mobile: dto.mobile,
|
|
role: dto.role!,
|
|
});
|
|
|
|
if (dto.role === UserRole.CUSTOMER) {
|
|
const dateOfBirth = dto.dateOfBirth ?? '2000-01-01';
|
|
const address = dto.address ?? '-';
|
|
|
|
await this.customersRepository.save({
|
|
userId: user.id,
|
|
dateOfBirth,
|
|
address,
|
|
});
|
|
}
|
|
|
|
return user;
|
|
}
|
|
|
|
private async issueTokens(user: User): Promise<AuthTokensResponse> {
|
|
const payload: JwtPayload = { sub: user.id, role: user.role };
|
|
|
|
const accessToken = this.jwtService.sign(payload);
|
|
const refreshToken = await this.createRefreshToken(user.id);
|
|
|
|
return {
|
|
accessToken,
|
|
refreshToken,
|
|
user: {
|
|
id: user.id,
|
|
firstName: user.firstName,
|
|
lastName: user.lastName,
|
|
mobile: user.mobile,
|
|
role: user.role,
|
|
},
|
|
};
|
|
}
|
|
|
|
private async createRefreshToken(userId: string): Promise<string> {
|
|
const refreshToken = this.jwtService.sign(
|
|
{ sub: userId },
|
|
{
|
|
secret: this.configService.getOrThrow<string>('JWT_REFRESH_SECRET'),
|
|
expiresIn: this.refreshExpiresIn as `${number}d`,
|
|
},
|
|
);
|
|
|
|
const tokenHash = await bcrypt.hash(refreshToken, 10);
|
|
const expiresAt = this.parseExpiresIn(this.refreshExpiresIn);
|
|
|
|
await this.refreshTokenRepository.save({
|
|
userId,
|
|
tokenHash,
|
|
expiresAt,
|
|
});
|
|
|
|
return refreshToken;
|
|
}
|
|
|
|
private generateOtpCode(): string {
|
|
const max = Math.pow(10, this.otpLength);
|
|
const min = Math.pow(10, this.otpLength - 1);
|
|
return String(Math.floor(min + Math.random() * (max - min)));
|
|
}
|
|
|
|
private parseExpiresIn(expiresIn: string): Date {
|
|
const match = expiresIn.match(/^(\d+)([dhms])$/);
|
|
if (!match) {
|
|
return new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);
|
|
}
|
|
|
|
const value = parseInt(match[1], 10);
|
|
const unit = match[2];
|
|
const multipliers: Record<string, number> = {
|
|
s: 1000,
|
|
m: 60 * 1000,
|
|
h: 60 * 60 * 1000,
|
|
d: 24 * 60 * 60 * 1000,
|
|
};
|
|
|
|
return new Date(Date.now() + value * multipliers[unit]);
|
|
}
|
|
}
|