This commit is contained in:
2026-06-28 20:32:52 +03:30
parent 7a5b3f0bea
commit bf35179d55
45 changed files with 1555 additions and 46 deletions
+296
View File
@@ -0,0 +1,296 @@
import {
BadRequestException,
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 { SmsService } from '../sms/sms.service';
import { UserRole } from '../users/enums/user-role.enum';
import { User } from '../users/entities/user.entity';
import { UsersService } from '../users/users.service';
import { VerifyOtpDto } from './dto/verify-otp.dto';
import { Otp } from './entities/otp.entity';
import { RefreshToken } from './entities/refresh-token.entity';
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;
};
}
@Injectable()
export class AuthService {
private readonly logger = new Logger(AuthService.name);
private readonly otpLength: number;
private readonly otpExpiresInMinutes: number;
private readonly otpMaxAttempts: number;
private readonly otpCooldownSeconds: number;
private readonly otpDebug: boolean;
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>,
private readonly usersService: UsersService,
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.otpMaxAttempts = parseInt(
this.configService.get<string>('OTP_MAX_ATTEMPTS') ?? '5',
10,
);
this.otpCooldownSeconds = parseInt(
this.configService.get<string>('OTP_COOLDOWN_SECONDS') ?? '60',
10,
);
this.otpDebug = this.configService.get<string>('OTP_DEBUG') === 'true';
this.refreshExpiresIn =
this.configService.get<string>('JWT_REFRESH_EXPIRES_IN') ?? '7d';
}
async requestOtp(mobile: string): Promise<{ message: 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() + this.otpExpiresInMinutes * 60 * 1000,
);
await this.otpRepository.save({
mobile,
code: hashedCode,
expiresAt,
});
const sent = await this.smsService.sendSms(mobile, `کد ورود: ${code}`);
if (!sent) {
throw new BadRequestException('Failed to send OTP');
}
if (
this.otpDebug &&
this.configService.get<string>('NODE_ENV') === 'development'
) {
this.logger.debug(`OTP for ${mobile}: ${code}`);
}
return { message: 'OTP sent' };
}
async verifyOtp(dto: VerifyOtpDto): Promise<AuthTokensResponse> {
const otp = await this.otpRepository.findOne({
where: { mobile: dto.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(dto.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);
const user = await this.resolveUserForLogin(dto);
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');
}
private async resolveUserForLogin(dto: VerifyOtpDto): Promise<User> {
const existingUser = await this.usersService.findByMobile(dto.mobile);
if (existingUser) {
return existingUser;
}
return this.createCustomerUser(dto);
}
private async createCustomerUser(dto: VerifyOtpDto): Promise<User> {
const firstName = dto.firstName ?? 'کاربر';
const lastName = dto.lastName ?? dto.mobile.slice(-4);
const dateOfBirth = dto.dateOfBirth ?? '2000-01-01';
const address = dto.address ?? '-';
const user = await this.usersService.create({
firstName,
lastName,
mobile: dto.mobile,
role: UserRole.CUSTOMER,
});
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]);
}
}