change flow
This commit is contained in:
@@ -16,9 +16,14 @@ import { AuthService } from './auth.service';
|
||||
import { CurrentUser } from './decorators/current-user.decorator';
|
||||
import { Public } from './decorators/public.decorator';
|
||||
import { AuthTokensResponseDto } from './dto/auth-tokens-response.dto';
|
||||
import { CheckSalonMobileDto } from './dto/check-salon-mobile.dto';
|
||||
import { CheckSalonMobileResponseDto } from './dto/check-salon-mobile-response.dto';
|
||||
import { RefreshTokenDto } from './dto/refresh-token.dto';
|
||||
import { RequestOtpDto } from './dto/request-otp.dto';
|
||||
import { RequestSalonOtpResponseDto } from './dto/request-salon-otp-response.dto';
|
||||
import { SalonAuthResponseDto } from './dto/salon-auth-response.dto';
|
||||
import { VerifyOtpDto } from './dto/verify-otp.dto';
|
||||
import { VerifySalonOtpDto } from './dto/verify-salon-otp.dto';
|
||||
import { AuthUser } from './interfaces/auth-user.interface';
|
||||
|
||||
@ApiTags('Auth')
|
||||
@@ -27,6 +32,35 @@ import { AuthUser } from './interfaces/auth-user.interface';
|
||||
export class AuthController {
|
||||
constructor(private readonly authService: AuthService) {}
|
||||
|
||||
@Public()
|
||||
@ApiPublicEndpoint('Check whether a salon-owner account exists for a mobile')
|
||||
@ApiStandardOkResponse(CheckSalonMobileResponseDto)
|
||||
@Post('salon/check-mobile')
|
||||
checkSalonMobile(@Body() dto: CheckSalonMobileDto) {
|
||||
return this.authService.checkSalonMobile(dto.mobile);
|
||||
}
|
||||
|
||||
@Public()
|
||||
@ApiPublicEndpoint(
|
||||
'Send OTP for salon login/signup. Signup OTP expires later (15m default). Call this after the signup form is filled.',
|
||||
)
|
||||
@ApiStandardOkResponse(RequestSalonOtpResponseDto)
|
||||
@ApiStandardTooManyRequestsResponse()
|
||||
@Post('salon/otp/request')
|
||||
requestSalonOtp(@Body() dto: RequestOtpDto) {
|
||||
return this.authService.requestSalonOtp(dto.mobile);
|
||||
}
|
||||
|
||||
@Public()
|
||||
@ApiPublicEndpoint(
|
||||
'Verify salon OTP. Existing owners only need mobile+code. New owners must also send owner and salon fields.',
|
||||
)
|
||||
@ApiStandardOkResponse(SalonAuthResponseDto)
|
||||
@Post('salon/otp/verify')
|
||||
verifySalonOtp(@Body() dto: VerifySalonOtpDto) {
|
||||
return this.authService.verifySalonOtp(dto);
|
||||
}
|
||||
|
||||
@Public()
|
||||
@ApiPublicEndpoint('Request OTP code via SMS')
|
||||
@ApiStandardMessageResponse('OTP sent')
|
||||
@@ -37,7 +71,9 @@ export class AuthController {
|
||||
}
|
||||
|
||||
@Public()
|
||||
@ApiPublicEndpoint('Verify OTP and receive access/refresh tokens')
|
||||
@ApiPublicEndpoint(
|
||||
'Verify OTP and receive access/refresh tokens. New users must send role (customer or salon-owner).',
|
||||
)
|
||||
@ApiStandardOkResponse(AuthTokensResponseDto)
|
||||
@Post('otp/verify')
|
||||
verifyOtp(@Body() dto: VerifyOtpDto) {
|
||||
|
||||
@@ -6,6 +6,7 @@ import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { AuthorizationModule } from '../authorization/authorization.module';
|
||||
import { Customer } from '../customers/entities/customer.entity';
|
||||
import { Salon } from '../salons/entities/salon.entity';
|
||||
import { SmsModule } from '../sms/sms.module';
|
||||
import { UsersModule } from '../users/users.module';
|
||||
import { AuthController } from './auth.controller';
|
||||
@@ -17,7 +18,7 @@ import { JwtStrategy } from './strategies/jwt.strategy';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([Otp, RefreshToken, Customer]),
|
||||
TypeOrmModule.forFeature([Otp, RefreshToken, Customer, Salon]),
|
||||
PassportModule.register({ defaultStrategy: 'jwt' }),
|
||||
JwtModule.registerAsync({
|
||||
imports: [ConfigModule],
|
||||
|
||||
+153
-18
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
HttpException,
|
||||
HttpStatus,
|
||||
Injectable,
|
||||
@@ -12,11 +13,13 @@ 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 { 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 { VerifySalonOtpDto } from './dto/verify-salon-otp.dto';
|
||||
import { Otp } from './entities/otp.entity';
|
||||
import { RefreshToken } from './entities/refresh-token.entity';
|
||||
import { JwtPayload } from './interfaces/jwt-payload.interface';
|
||||
@@ -33,11 +36,16 @@ export interface AuthTokensResponse {
|
||||
};
|
||||
}
|
||||
|
||||
export interface SalonAuthResponse extends AuthTokensResponse {
|
||||
salon?: Salon;
|
||||
}
|
||||
|
||||
@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 otpDebug: boolean;
|
||||
@@ -50,6 +58,8 @@ export class AuthService {
|
||||
private readonly refreshTokenRepository: Repository<RefreshToken>,
|
||||
@InjectRepository(Customer)
|
||||
private readonly customersRepository: Repository<Customer>,
|
||||
@InjectRepository(Salon)
|
||||
private readonly salonsRepository: Repository<Salon>,
|
||||
private readonly usersService: UsersService,
|
||||
private readonly smsService: SmsService,
|
||||
private readonly jwtService: JwtService,
|
||||
@@ -63,6 +73,11 @@ export class AuthService {
|
||||
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,
|
||||
@@ -77,6 +92,60 @@ export class AuthService {
|
||||
}
|
||||
|
||||
async requestOtp(mobile: string): Promise<{ message: string }> {
|
||||
await this.sendOtp(mobile, this.otpExpiresInMinutes);
|
||||
return { message: 'OTP sent' };
|
||||
}
|
||||
|
||||
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;
|
||||
}> {
|
||||
await this.ensureSalonMobileCanProceed(mobile);
|
||||
const isRegistered = await this.isSalonOwnerRegistered(mobile);
|
||||
const expiresInMinutes = isRegistered
|
||||
? this.otpExpiresInMinutes
|
||||
: this.otpSalonSignupExpiresInMinutes;
|
||||
|
||||
await this.sendOtp(mobile, expiresInMinutes);
|
||||
|
||||
return {
|
||||
message: 'OTP sent',
|
||||
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<void> {
|
||||
const recentOtp = await this.otpRepository.findOne({
|
||||
where: { mobile, verified: false },
|
||||
order: { createdAt: 'DESC' },
|
||||
@@ -97,9 +166,7 @@ export class AuthService {
|
||||
|
||||
const code = this.generateOtpCode();
|
||||
const hashedCode = await bcrypt.hash(code, 10);
|
||||
const expiresAt = new Date(
|
||||
Date.now() + this.otpExpiresInMinutes * 60 * 1000,
|
||||
);
|
||||
const expiresAt = new Date(Date.now() + expiresInMinutes * 60 * 1000);
|
||||
|
||||
await this.otpRepository.save({
|
||||
mobile,
|
||||
@@ -119,13 +186,11 @@ export class AuthService {
|
||||
) {
|
||||
this.logger.debug(`OTP for ${mobile}: ${code}`);
|
||||
}
|
||||
|
||||
return { message: 'OTP sent' };
|
||||
}
|
||||
|
||||
async verifyOtp(dto: VerifyOtpDto): Promise<AuthTokensResponse> {
|
||||
private async consumeOtp(mobile: string, code: string): Promise<void> {
|
||||
const otp = await this.otpRepository.findOne({
|
||||
where: { mobile: dto.mobile, verified: false },
|
||||
where: { mobile, verified: false },
|
||||
order: { createdAt: 'DESC' },
|
||||
});
|
||||
|
||||
@@ -141,7 +206,7 @@ export class AuthService {
|
||||
throw new UnauthorizedException('Too many failed attempts');
|
||||
}
|
||||
|
||||
const isValid = await bcrypt.compare(dto.code, otp.code);
|
||||
const isValid = await bcrypt.compare(code, otp.code);
|
||||
if (!isValid) {
|
||||
otp.attempts += 1;
|
||||
await this.otpRepository.save(otp);
|
||||
@@ -150,6 +215,67 @@ export class AuthService {
|
||||
|
||||
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 registerSalonOwner(
|
||||
dto: VerifySalonOtpDto,
|
||||
): Promise<SalonAuthResponse> {
|
||||
this.validateSalonSignupFields(dto);
|
||||
|
||||
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!,
|
||||
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',
|
||||
];
|
||||
|
||||
const missingFields = requiredFields.filter((field) => !dto[field]);
|
||||
|
||||
if (missingFields.length > 0) {
|
||||
throw new BadRequestException(
|
||||
`Missing required fields for salon registration: ${missingFields.join(', ')}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async verifyOtp(dto: VerifyOtpDto): Promise<AuthTokensResponse> {
|
||||
await this.consumeOtp(dto.mobile, dto.code);
|
||||
|
||||
const user = await this.resolveUserForLogin(dto);
|
||||
return this.issueTokens(user);
|
||||
@@ -205,27 +331,36 @@ export class AuthService {
|
||||
return existingUser;
|
||||
}
|
||||
|
||||
return this.createCustomerUser(dto);
|
||||
if (!dto.role) {
|
||||
throw new BadRequestException(
|
||||
'role is required when registering a new account',
|
||||
);
|
||||
}
|
||||
|
||||
return this.createUserFromOtp(dto);
|
||||
}
|
||||
|
||||
private async createCustomerUser(dto: VerifyOtpDto): Promise<User> {
|
||||
private async createUserFromOtp(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,
|
||||
role: dto.role!,
|
||||
});
|
||||
|
||||
await this.customersRepository.save({
|
||||
userId: user.id,
|
||||
dateOfBirth,
|
||||
address,
|
||||
});
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class CheckSalonMobileResponseDto {
|
||||
@ApiProperty({
|
||||
description: 'Whether a salon-owner account exists for this mobile',
|
||||
})
|
||||
isRegistered: boolean;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { Matches } from 'class-validator';
|
||||
|
||||
export class CheckSalonMobileDto {
|
||||
@Matches(/^09\d{9}$/)
|
||||
mobile: string;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class RequestSalonOtpResponseDto {
|
||||
@ApiProperty({ example: 'OTP sent' })
|
||||
message: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Whether a salon-owner account exists for this mobile',
|
||||
})
|
||||
isRegistered: boolean;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'OTP validity window in seconds',
|
||||
example: 900,
|
||||
})
|
||||
expiresInSeconds: number;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Salon } from '../../salons/entities/salon.entity';
|
||||
import { AuthTokensResponseDto } from './auth-tokens-response.dto';
|
||||
|
||||
export class SalonAuthResponseDto extends AuthTokensResponseDto {
|
||||
@ApiPropertyOptional({
|
||||
type: Salon,
|
||||
description: 'Present only when a new salon account is created',
|
||||
})
|
||||
salon?: Salon;
|
||||
}
|
||||
@@ -1,10 +1,14 @@
|
||||
import {
|
||||
IsDateString,
|
||||
IsIn,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Length,
|
||||
Matches,
|
||||
} from 'class-validator';
|
||||
import { UserRole } from '../../users/enums/user-role.enum';
|
||||
|
||||
export const OTP_SIGNUP_ROLES = [UserRole.CUSTOMER, UserRole.SALON] as const;
|
||||
|
||||
export class VerifyOtpDto {
|
||||
@Matches(/^09\d{9}$/)
|
||||
@@ -32,4 +36,8 @@ export class VerifyOtpDto {
|
||||
@IsString()
|
||||
@Length(1, 500)
|
||||
address?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(OTP_SIGNUP_ROLES)
|
||||
role?: (typeof OTP_SIGNUP_ROLES)[number];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import {
|
||||
IsOptional,
|
||||
IsString,
|
||||
Length,
|
||||
Matches,
|
||||
} from 'class-validator';
|
||||
|
||||
export class VerifySalonOtpDto {
|
||||
@Matches(/^09\d{9}$/)
|
||||
mobile: string;
|
||||
|
||||
@IsString()
|
||||
@Length(4, 6)
|
||||
code: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Length(1, 100)
|
||||
firstName?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Length(1, 100)
|
||||
lastName?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Length(1, 200)
|
||||
salonName?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
salonDescription?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Length(1, 500)
|
||||
salonAddress?: string;
|
||||
|
||||
@IsOptional()
|
||||
@Matches(/^09\d{9}$/)
|
||||
salonPhone?: string;
|
||||
}
|
||||
@@ -17,6 +17,8 @@ export default () => {
|
||||
'https://api.sms-webservice.com/api/V3',
|
||||
OTP_LENGTH: process.env.OTP_LENGTH ?? '5',
|
||||
OTP_EXPIRES_IN_MINUTES: process.env.OTP_EXPIRES_IN_MINUTES ?? '5',
|
||||
OTP_SALON_SIGNUP_EXPIRES_IN_MINUTES:
|
||||
process.env.OTP_SALON_SIGNUP_EXPIRES_IN_MINUTES ?? '15',
|
||||
OTP_MAX_ATTEMPTS: process.env.OTP_MAX_ATTEMPTS ?? '5',
|
||||
OTP_COOLDOWN_SECONDS: process.env.OTP_COOLDOWN_SECONDS ?? '60',
|
||||
OTP_DEBUG: process.env.OTP_DEBUG ?? 'false',
|
||||
|
||||
Reference in New Issue
Block a user