auth
This commit is contained in:
@@ -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]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user