هذا الالتزام موجود في:
@@ -1,5 +1,18 @@
|
||||
import { Body, Controller, Get, HttpCode, HttpStatus, Param, Post, Req, UseGuards } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Param,
|
||||
Post,
|
||||
Req,
|
||||
UploadedFiles,
|
||||
UseGuards,
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
import { FileFieldsInterceptor } from '@nestjs/platform-express';
|
||||
import { ApiBearerAuth, ApiBody, ApiConsumes, ApiTags } from '@nestjs/swagger';
|
||||
import { Request } from 'express';
|
||||
import { Throttle } from '../../common/decorators/throttle.decorator';
|
||||
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||
@@ -19,6 +32,9 @@ import { RefreshTokenDto } from './dto/refresh-token.dto';
|
||||
import { GoogleAuthGuard } from './guards/google-auth.guard';
|
||||
import { RegisterDto } from './dto/register.dto';
|
||||
import { SendEmailVerificationDto } from './dto/send-email-verification.dto';
|
||||
import { SignupCompleteDto } from './dto/signup-complete.dto';
|
||||
import { SignupStartDto } from './dto/signup-start.dto';
|
||||
import { SignupVerifyDto } from './dto/signup-verify.dto';
|
||||
import { SuperAdminLoginDto } from './dto/super-admin-login.dto';
|
||||
import { VerifyEmailDto } from './dto/verify-email.dto';
|
||||
import { VerifyResetCodeDto } from './dto/verify-reset-code.dto';
|
||||
@@ -41,6 +57,62 @@ export class AuthController {
|
||||
return this.authService.registerBasic(dto);
|
||||
}
|
||||
|
||||
@Post('signup/start')
|
||||
@Throttle(10, 60_000)
|
||||
async signupStart(@Body() dto: SignupStartDto) {
|
||||
return this.authService.signupStart(dto);
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('signup/verify')
|
||||
@Throttle(20, 60_000)
|
||||
async signupVerify(@Body() dto: SignupVerifyDto) {
|
||||
return this.authService.signupVerify(dto);
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('signup/complete')
|
||||
@Throttle(10, 60_000)
|
||||
@UseInterceptors(FileFieldsInterceptor([{ name: 'avatarFile', maxCount: 1 }]))
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiBody({
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
signupToken: { type: 'string' },
|
||||
musicRoles: { type: 'array', items: { type: 'string' } },
|
||||
musicGenres: { type: 'array', items: { type: 'string' } },
|
||||
experienceLevel: { type: 'string' },
|
||||
favoriteInstruments: { type: 'array', items: { type: 'string' } },
|
||||
favoriteMaqamat: { type: 'array', items: { type: 'string' } },
|
||||
stageName: { type: 'string' },
|
||||
name: { type: 'string' },
|
||||
username: { type: 'string' },
|
||||
bio: { type: 'string' },
|
||||
location: { type: 'string' },
|
||||
latitude: { type: 'number' },
|
||||
longitude: { type: 'number' },
|
||||
isPrivate: { type: 'boolean' },
|
||||
avatarFile: { type: 'string', format: 'binary' },
|
||||
},
|
||||
required: ['signupToken', 'name'],
|
||||
},
|
||||
})
|
||||
async signupComplete(
|
||||
@Body() dto: SignupCompleteDto,
|
||||
@UploadedFiles()
|
||||
files?: {
|
||||
avatarFile?: Array<{
|
||||
mimetype?: string;
|
||||
size: number;
|
||||
buffer: Buffer;
|
||||
originalname?: string;
|
||||
}>;
|
||||
},
|
||||
) {
|
||||
return this.authService.signupComplete(dto, files?.avatarFile?.[0]);
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('login')
|
||||
@Throttle(20, 60_000)
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
PasswordResetCodeSchema,
|
||||
} from './schemas/password-reset-code.schema';
|
||||
import { RefreshToken, RefreshTokenSchema } from './schemas/refresh-token.schema';
|
||||
import { SignupDraft, SignupDraftSchema } from './schemas/signup-draft.schema';
|
||||
import {
|
||||
SuperAdminRefreshToken,
|
||||
SuperAdminRefreshTokenSchema,
|
||||
@@ -54,6 +55,10 @@ import { SuperAdminJwtStrategy } from './strategies/super-admin-jwt.strategy';
|
||||
name: EmailVerificationCode.name,
|
||||
schema: EmailVerificationCodeSchema,
|
||||
},
|
||||
{
|
||||
name: SignupDraft.name,
|
||||
schema: SignupDraftSchema,
|
||||
},
|
||||
]),
|
||||
EmailModule,
|
||||
UsersModule,
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
PasswordResetCodeDocument,
|
||||
} from './schemas/password-reset-code.schema';
|
||||
import { RefreshToken, RefreshTokenDocument } from './schemas/refresh-token.schema';
|
||||
import { SignupDraft, SignupDraftDocument } from './schemas/signup-draft.schema';
|
||||
import {
|
||||
SuperAdminRefreshToken,
|
||||
SuperAdminRefreshTokenDocument,
|
||||
@@ -26,6 +27,8 @@ export class AuthRepository {
|
||||
private readonly passwordResetCodeModel: Model<PasswordResetCodeDocument>,
|
||||
@InjectModel(EmailVerificationCode.name)
|
||||
private readonly emailVerificationCodeModel: Model<EmailVerificationCodeDocument>,
|
||||
@InjectModel(SignupDraft.name)
|
||||
private readonly signupDraftModel: Model<SignupDraftDocument>,
|
||||
) {}
|
||||
|
||||
async createRefreshToken(userId: string, jti: string, tokenHash: string, expiresAt: Date): Promise<void> {
|
||||
@@ -280,4 +283,81 @@ export class AuthRepository {
|
||||
.updateMany({ userId: new Types.ObjectId(userId), used: false }, { used: true })
|
||||
.exec();
|
||||
}
|
||||
|
||||
async upsertSignupDraft(params: {
|
||||
email: string;
|
||||
passwordHash: string;
|
||||
codeHash: string;
|
||||
codeExpiresAt: Date;
|
||||
expiresAt: Date;
|
||||
}): Promise<SignupDraftDocument> {
|
||||
return this.signupDraftModel
|
||||
.findOneAndUpdate(
|
||||
{
|
||||
email: params.email.toLowerCase(),
|
||||
consumed: false,
|
||||
expiresAt: { $gt: new Date() },
|
||||
},
|
||||
{
|
||||
$set: {
|
||||
email: params.email.toLowerCase(),
|
||||
passwordHash: params.passwordHash,
|
||||
codeHash: params.codeHash,
|
||||
codeExpiresAt: params.codeExpiresAt,
|
||||
expiresAt: params.expiresAt,
|
||||
verifiedAt: null,
|
||||
signupTokenHash: '',
|
||||
signupTokenExpiresAt: null,
|
||||
attempts: 0,
|
||||
consumed: false,
|
||||
},
|
||||
},
|
||||
{ new: true, upsert: true, setDefaultsOnInsert: true },
|
||||
)
|
||||
.select('+passwordHash +codeHash +signupTokenHash')
|
||||
.exec();
|
||||
}
|
||||
|
||||
async findSignupDraftWithSecrets(id: string): Promise<SignupDraftDocument | null> {
|
||||
if (!Types.ObjectId.isValid(id)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.signupDraftModel
|
||||
.findById(id)
|
||||
.select('+passwordHash +codeHash +signupTokenHash')
|
||||
.exec();
|
||||
}
|
||||
|
||||
async findSignupDraftByTokenHash(signupTokenHash: string): Promise<SignupDraftDocument | null> {
|
||||
return this.signupDraftModel
|
||||
.findOne({
|
||||
signupTokenHash,
|
||||
consumed: false,
|
||||
})
|
||||
.select('+passwordHash +codeHash +signupTokenHash')
|
||||
.exec();
|
||||
}
|
||||
|
||||
async incrementSignupDraftAttempts(id: string): Promise<void> {
|
||||
await this.signupDraftModel.findByIdAndUpdate(id, { $inc: { attempts: 1 } }).exec();
|
||||
}
|
||||
|
||||
async markSignupDraftVerified(
|
||||
id: string,
|
||||
signupTokenHash: string,
|
||||
signupTokenExpiresAt: Date,
|
||||
): Promise<void> {
|
||||
await this.signupDraftModel
|
||||
.findByIdAndUpdate(id, {
|
||||
verifiedAt: new Date(),
|
||||
signupTokenHash,
|
||||
signupTokenExpiresAt,
|
||||
})
|
||||
.exec();
|
||||
}
|
||||
|
||||
async markSignupDraftConsumed(id: string): Promise<void> {
|
||||
await this.signupDraftModel.findByIdAndUpdate(id, { consumed: true }).exec();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { UnauthorizedException } from '@nestjs/common';
|
||||
import { BadRequestException, ConflictException, UnauthorizedException } from '@nestjs/common';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { validate } from 'class-validator';
|
||||
@@ -185,3 +185,269 @@ describe('AuthService Google token login', () => {
|
||||
expect(authServiceSource).not.toContain('admin.auth().verifyIdToken');
|
||||
});
|
||||
});
|
||||
|
||||
describe('AuthService deferred signup flow', () => {
|
||||
const createDeferredSignupService = (overrides?: {
|
||||
usersService?: Record<string, jest.Mock>;
|
||||
authRepository?: Record<string, jest.Mock>;
|
||||
jwtService?: Record<string, jest.Mock>;
|
||||
}) => {
|
||||
const usersService = {
|
||||
findByEmail: jest.fn().mockResolvedValue(null),
|
||||
findByUsername: jest.fn().mockResolvedValue(null),
|
||||
createWithOptionalAvatarFile: jest.fn(),
|
||||
findByIdOrFail: jest.fn(),
|
||||
rollbackSignupCreatedUser: jest.fn(),
|
||||
...(overrides?.usersService ?? {}),
|
||||
};
|
||||
const authRepository = {
|
||||
upsertSignupDraft: jest.fn().mockResolvedValue({ id: 'draft-1' }),
|
||||
findSignupDraftWithSecrets: jest.fn(),
|
||||
incrementSignupDraftAttempts: jest.fn().mockResolvedValue(undefined),
|
||||
markSignupDraftVerified: jest.fn().mockResolvedValue(undefined),
|
||||
findSignupDraftByTokenHash: jest.fn(),
|
||||
markSignupDraftConsumed: jest.fn().mockResolvedValue(undefined),
|
||||
createRefreshToken: jest.fn().mockResolvedValue(undefined),
|
||||
...(overrides?.authRepository ?? {}),
|
||||
};
|
||||
const jwtService = {
|
||||
signAsync: jest.fn().mockResolvedValueOnce('access-token').mockResolvedValueOnce('refresh-token'),
|
||||
...(overrides?.jwtService ?? {}),
|
||||
};
|
||||
const configService = {
|
||||
get: jest.fn((key: string) => {
|
||||
const values: Record<string, string | number> = {
|
||||
nodeEnv: 'test',
|
||||
'security.bcryptSaltRounds': 4,
|
||||
'emailVerification.maxAttempts': 5,
|
||||
'jwt.accessSecret': 'access-secret',
|
||||
'jwt.accessExpiresIn': '15m',
|
||||
'jwt.refreshSecret': 'refresh-secret',
|
||||
'jwt.refreshExpiresIn': '30d',
|
||||
'security.refreshTokenHashSecret': 'refresh-hash-secret',
|
||||
};
|
||||
return values[key];
|
||||
}),
|
||||
};
|
||||
const emailService = {
|
||||
sendVerificationCode: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
const service = new AuthService(
|
||||
usersService as any,
|
||||
authRepository as any,
|
||||
jwtService as any,
|
||||
configService as any,
|
||||
emailService as any,
|
||||
);
|
||||
|
||||
return { service, usersService, authRepository, jwtService, emailService };
|
||||
};
|
||||
|
||||
it('signup/start sends an OTP and does not create a user', async () => {
|
||||
const { service, usersService, authRepository, emailService } = createDeferredSignupService();
|
||||
|
||||
const result = await service.signupStart({
|
||||
email: 'New.User@Example.com',
|
||||
password: 'StrongPass123!',
|
||||
confirmPassword: 'StrongPass123!',
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
message: 'Verification code sent',
|
||||
email: 'new.user@example.com',
|
||||
signupId: 'draft-1',
|
||||
});
|
||||
expect(result.debugCode).toMatch(/^\d{6}$/);
|
||||
expect(usersService.findByEmail).toHaveBeenCalledWith('new.user@example.com');
|
||||
expect(usersService.createWithOptionalAvatarFile).not.toHaveBeenCalled();
|
||||
expect(authRepository.upsertSignupDraft).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ email: 'new.user@example.com' }),
|
||||
);
|
||||
expect(emailService.sendVerificationCode).toHaveBeenCalledWith(
|
||||
'new.user@example.com',
|
||||
expect.stringMatching(/^\d{6}$/),
|
||||
20,
|
||||
);
|
||||
});
|
||||
|
||||
it('signup/start fails if the email is already used', async () => {
|
||||
const { service, authRepository } = createDeferredSignupService({
|
||||
usersService: { findByEmail: jest.fn().mockResolvedValue({ id: 'existing-user' }) },
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.signupStart({
|
||||
email: 'used@example.com',
|
||||
password: 'StrongPass123!',
|
||||
confirmPassword: 'StrongPass123!',
|
||||
}),
|
||||
).rejects.toBeInstanceOf(ConflictException);
|
||||
expect(authRepository.upsertSignupDraft).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('signup/start fails if password confirmation does not match', async () => {
|
||||
const { service, authRepository } = createDeferredSignupService();
|
||||
|
||||
await expect(
|
||||
service.signupStart({
|
||||
email: 'new@example.com',
|
||||
password: 'StrongPass123!',
|
||||
confirmPassword: 'DifferentPass123!',
|
||||
}),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(authRepository.upsertSignupDraft).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('signup/verify returns a signup token without auth tokens', async () => {
|
||||
const codeHash = await hashValue('123456', 4);
|
||||
const { service, authRepository, usersService } = createDeferredSignupService();
|
||||
authRepository.findSignupDraftWithSecrets.mockResolvedValue({
|
||||
id: 'draft-1',
|
||||
email: 'new@example.com',
|
||||
codeHash,
|
||||
codeExpiresAt: new Date(Date.now() + 60_000),
|
||||
expiresAt: new Date(Date.now() + 60_000),
|
||||
attempts: 0,
|
||||
consumed: false,
|
||||
});
|
||||
|
||||
const result = await service.signupVerify({
|
||||
signupId: 'draft-1',
|
||||
email: 'new@example.com',
|
||||
code: '123456',
|
||||
});
|
||||
|
||||
expect(result.message).toBe('Email verified');
|
||||
expect(result.signupToken).toEqual(expect.any(String));
|
||||
expect((result as any).accessToken).toBeUndefined();
|
||||
expect((result as any).refreshToken).toBeUndefined();
|
||||
expect(authRepository.markSignupDraftVerified).toHaveBeenCalledWith(
|
||||
'draft-1',
|
||||
expect.stringMatching(/^sha256:/),
|
||||
expect.any(Date),
|
||||
);
|
||||
expect(usersService.createWithOptionalAvatarFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('signup/verify fails with a wrong code', async () => {
|
||||
const codeHash = await hashValue('123456', 4);
|
||||
const { service, authRepository } = createDeferredSignupService();
|
||||
authRepository.findSignupDraftWithSecrets.mockResolvedValue({
|
||||
id: 'draft-1',
|
||||
email: 'new@example.com',
|
||||
codeHash,
|
||||
codeExpiresAt: new Date(Date.now() + 60_000),
|
||||
expiresAt: new Date(Date.now() + 60_000),
|
||||
attempts: 0,
|
||||
consumed: false,
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.signupVerify({
|
||||
signupId: 'draft-1',
|
||||
email: 'new@example.com',
|
||||
code: '654321',
|
||||
}),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(authRepository.incrementSignupDraftAttempts).toHaveBeenCalledWith('draft-1');
|
||||
});
|
||||
|
||||
it('signup/complete creates the user and returns auth tokens', async () => {
|
||||
const createdUser = {
|
||||
id: 'user-1',
|
||||
username: 'new_user',
|
||||
role: 'user',
|
||||
avatar: '',
|
||||
};
|
||||
const safeUser = {
|
||||
toObject: () => ({ id: 'user-1', email: 'new@example.com', username: 'new_user' }),
|
||||
};
|
||||
const { service, usersService, authRepository } = createDeferredSignupService();
|
||||
authRepository.findSignupDraftByTokenHash.mockResolvedValue({
|
||||
id: 'draft-1',
|
||||
email: 'new@example.com',
|
||||
passwordHash: 'hashed-password',
|
||||
verifiedAt: new Date(),
|
||||
signupTokenExpiresAt: new Date(Date.now() + 60_000),
|
||||
expiresAt: new Date(Date.now() + 60_000),
|
||||
consumed: false,
|
||||
});
|
||||
usersService.createWithOptionalAvatarFile.mockResolvedValue(createdUser);
|
||||
usersService.findByIdOrFail.mockResolvedValue(safeUser);
|
||||
|
||||
const result = await service.signupComplete({
|
||||
signupToken: 'temporary-token',
|
||||
name: 'New User',
|
||||
username: 'new_user',
|
||||
musicRoles: [],
|
||||
musicGenres: ['Tarab'],
|
||||
favoriteInstruments: ['Oud'],
|
||||
favoriteMaqamat: ['Bayati'],
|
||||
});
|
||||
|
||||
expect(usersService.createWithOptionalAvatarFile).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
email: 'new@example.com',
|
||||
password: 'hashed-password',
|
||||
isEmailVerified: true,
|
||||
}),
|
||||
undefined,
|
||||
);
|
||||
expect(result).toMatchObject({
|
||||
message: 'Account created successfully',
|
||||
accessToken: 'access-token',
|
||||
refreshToken: 'refresh-token',
|
||||
user: { id: 'user-1' },
|
||||
});
|
||||
expect(authRepository.markSignupDraftConsumed).toHaveBeenCalledWith('draft-1');
|
||||
});
|
||||
|
||||
it('signup/complete fails with an invalid signup token', async () => {
|
||||
const { service, usersService } = createDeferredSignupService();
|
||||
|
||||
await expect(
|
||||
service.signupComplete({
|
||||
signupToken: 'bad-token',
|
||||
name: 'New User',
|
||||
}),
|
||||
).rejects.toBeInstanceOf(UnauthorizedException);
|
||||
expect(usersService.createWithOptionalAvatarFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rolls back the created user if complete fails after user creation', async () => {
|
||||
const createdUser = {
|
||||
id: 'user-1',
|
||||
username: 'new_user',
|
||||
role: 'user',
|
||||
avatar: 'uploads/users/avatar/avatar.webp',
|
||||
};
|
||||
const { service, usersService, authRepository } = createDeferredSignupService({
|
||||
authRepository: {
|
||||
markSignupDraftConsumed: jest.fn().mockRejectedValue(new Error('draft update failed')),
|
||||
},
|
||||
});
|
||||
authRepository.findSignupDraftByTokenHash.mockResolvedValue({
|
||||
id: 'draft-1',
|
||||
email: 'new@example.com',
|
||||
passwordHash: 'hashed-password',
|
||||
verifiedAt: new Date(),
|
||||
signupTokenExpiresAt: new Date(Date.now() + 60_000),
|
||||
expiresAt: new Date(Date.now() + 60_000),
|
||||
consumed: false,
|
||||
});
|
||||
usersService.createWithOptionalAvatarFile.mockResolvedValue(createdUser);
|
||||
|
||||
await expect(
|
||||
service.signupComplete({
|
||||
signupToken: 'temporary-token',
|
||||
name: 'New User',
|
||||
username: 'new_user',
|
||||
}),
|
||||
).rejects.toThrow('draft update failed');
|
||||
expect(usersService.rollbackSignupCreatedUser).toHaveBeenCalledWith(
|
||||
'user-1',
|
||||
'uploads/users/avatar/avatar.webp',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
ForbiddenException,
|
||||
GoneException,
|
||||
HttpException,
|
||||
HttpStatus,
|
||||
Injectable,
|
||||
Logger,
|
||||
UnauthorizedException,
|
||||
@@ -26,6 +30,9 @@ import { ResetPasswordDto } from './dto/reset-password.dto';
|
||||
import { RefreshTokenDto } from './dto/refresh-token.dto';
|
||||
import { RegisterDto } from './dto/register.dto';
|
||||
import { SendEmailVerificationDto } from './dto/send-email-verification.dto';
|
||||
import { SignupCompleteDto } from './dto/signup-complete.dto';
|
||||
import { SignupStartDto } from './dto/signup-start.dto';
|
||||
import { SignupVerifyDto } from './dto/signup-verify.dto';
|
||||
import { SuperAdminLoginDto } from './dto/super-admin-login.dto';
|
||||
import { VerifyEmailDto } from './dto/verify-email.dto';
|
||||
import { VerifyResetCodeDto } from './dto/verify-reset-code.dto';
|
||||
@@ -33,6 +40,13 @@ import { AuthRepository } from './auth.repository';
|
||||
import { AuthResult, TokenPair } from './types/token-pair.type';
|
||||
import { DEFAULT_SUPERADMIN_PERMISSIONS } from '../superadmin/superadmin-permissions';
|
||||
|
||||
type UploadedImageFile = {
|
||||
mimetype?: string;
|
||||
size: number;
|
||||
buffer: Buffer;
|
||||
originalname?: string;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
private readonly googleOAuthClient = new OAuth2Client();
|
||||
@@ -94,6 +108,123 @@ export class AuthService {
|
||||
: { message, email: user.email };
|
||||
}
|
||||
|
||||
async signupStart(
|
||||
dto: SignupStartDto,
|
||||
): Promise<{ message: string; email: string; signupId: string; debugCode?: string }> {
|
||||
if (dto.password !== dto.confirmPassword) {
|
||||
throw new BadRequestException('Password confirmation does not match');
|
||||
}
|
||||
|
||||
const normalizedEmail = dto.email.toLowerCase();
|
||||
await this.assertEmailNotUsed(normalizedEmail);
|
||||
|
||||
const saltRounds = this.configService.get<number>('security.bcryptSaltRounds', { infer: true });
|
||||
const [passwordHash, code] = await Promise.all([
|
||||
hashValue(dto.password, saltRounds),
|
||||
Promise.resolve(this.generateResetCode()),
|
||||
]);
|
||||
const codeHash = await hashValue(code, saltRounds);
|
||||
const ttlMinutes = this.getSignupDraftTtlMinutes();
|
||||
const expiresAt = new Date(Date.now() + ttlMinutes * 60 * 1000);
|
||||
|
||||
const draft = await this.authRepository.upsertSignupDraft({
|
||||
email: normalizedEmail,
|
||||
passwordHash,
|
||||
codeHash,
|
||||
codeExpiresAt: expiresAt,
|
||||
expiresAt,
|
||||
});
|
||||
|
||||
await this.emailService.sendVerificationCode(normalizedEmail, code, ttlMinutes);
|
||||
this.logger.log(`Signup draft verification code generated for ${normalizedEmail}`);
|
||||
|
||||
const response = {
|
||||
message: 'Verification code sent',
|
||||
email: normalizedEmail,
|
||||
signupId: draft.id,
|
||||
};
|
||||
const nodeEnv = this.configService.get<string>('nodeEnv', { infer: true });
|
||||
return nodeEnv !== 'production' ? { ...response, debugCode: code } : response;
|
||||
}
|
||||
|
||||
async signupVerify(dto: SignupVerifyDto): Promise<{ message: string; signupToken: string }> {
|
||||
const normalizedEmail = dto.email.toLowerCase();
|
||||
const draft = await this.authRepository.findSignupDraftWithSecrets(dto.signupId);
|
||||
this.assertUsableSignupDraft(draft, normalizedEmail);
|
||||
|
||||
const maxAttempts = this.getSignupMaxAttempts();
|
||||
if (draft.attempts >= maxAttempts) {
|
||||
throw new HttpException('Too many verification attempts', HttpStatus.TOO_MANY_REQUESTS);
|
||||
}
|
||||
|
||||
const isMatch = await compareHash(dto.code, draft.codeHash);
|
||||
if (!isMatch) {
|
||||
await this.authRepository.incrementSignupDraftAttempts(draft.id);
|
||||
if (draft.attempts + 1 >= maxAttempts) {
|
||||
throw new HttpException('Too many verification attempts', HttpStatus.TOO_MANY_REQUESTS);
|
||||
}
|
||||
throw new BadRequestException('Invalid verification code');
|
||||
}
|
||||
|
||||
const signupToken = randomBytes(32).toString('base64url');
|
||||
const signupTokenHash = hashHighEntropyValue(signupToken, this.getSignupTokenHashSecret());
|
||||
const signupTokenExpiresAt = new Date(Date.now() + this.getSignupTokenTtlMinutes() * 60 * 1000);
|
||||
await this.authRepository.markSignupDraftVerified(draft.id, signupTokenHash, signupTokenExpiresAt);
|
||||
|
||||
return { message: 'Email verified', signupToken };
|
||||
}
|
||||
|
||||
async signupComplete(dto: SignupCompleteDto, avatarFile?: UploadedImageFile): Promise<AuthResult & { message: string }> {
|
||||
const signupTokenHash = hashHighEntropyValue(dto.signupToken, this.getSignupTokenHashSecret());
|
||||
const draft = await this.authRepository.findSignupDraftByTokenHash(signupTokenHash);
|
||||
this.assertCompletableSignupDraft(draft);
|
||||
|
||||
await this.assertEmailNotUsed(draft.email);
|
||||
|
||||
const username = dto.username?.trim()
|
||||
? dto.username.trim()
|
||||
: await this.generateUniqueUsernameFromEmail(draft.email);
|
||||
const createdUser = await this.usersService.createWithOptionalAvatarFile(
|
||||
{
|
||||
name: dto.name,
|
||||
stageName: dto.stageName,
|
||||
username,
|
||||
email: draft.email,
|
||||
password: draft.passwordHash,
|
||||
bio: dto.bio,
|
||||
location: dto.location,
|
||||
latitude: dto.latitude,
|
||||
longitude: dto.longitude,
|
||||
isPrivate: dto.isPrivate,
|
||||
musicRoles: dto.musicRoles,
|
||||
musicGenres: dto.musicGenres,
|
||||
experienceLevel: dto.experienceLevel,
|
||||
favoriteInstruments: dto.favoriteInstruments,
|
||||
favoriteMaqamat: dto.favoriteMaqamat,
|
||||
isEmailVerified: true,
|
||||
},
|
||||
avatarFile,
|
||||
);
|
||||
|
||||
try {
|
||||
const tokens = await this.generateAndStoreTokenPair(
|
||||
createdUser.id,
|
||||
createdUser.username,
|
||||
createdUser.role ?? 'user',
|
||||
);
|
||||
await this.authRepository.markSignupDraftConsumed(draft.id);
|
||||
const safeUser = await this.usersService.findByIdOrFail(createdUser.id);
|
||||
return {
|
||||
message: 'Account created successfully',
|
||||
...tokens,
|
||||
user: safeUser.toObject() as unknown as Record<string, unknown>,
|
||||
};
|
||||
} catch (error) {
|
||||
await this.usersService.rollbackSignupCreatedUser(createdUser.id, createdUser.avatar);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async login(dto: LoginDto): Promise<AuthResult> {
|
||||
const user = await this.usersService.findByEmailWithPassword(dto.email);
|
||||
if (!user || !user.password) {
|
||||
@@ -697,6 +828,62 @@ export class AuthService {
|
||||
);
|
||||
}
|
||||
|
||||
private getSignupTokenHashSecret(): string {
|
||||
return this.getRefreshTokenHashSecret();
|
||||
}
|
||||
|
||||
private getSignupDraftTtlMinutes(): number {
|
||||
const configured = this.configService.get<number>('signup.draftTtlMinutes', { infer: true });
|
||||
return this.clampMinutes(configured ?? 20, 15, 30);
|
||||
}
|
||||
|
||||
private getSignupTokenTtlMinutes(): number {
|
||||
const configured = this.configService.get<number>('signup.tokenTtlMinutes', { infer: true });
|
||||
return this.clampMinutes(configured ?? 15, 5, 30);
|
||||
}
|
||||
|
||||
private getSignupMaxAttempts(): number {
|
||||
return this.configService.get<number>('emailVerification.maxAttempts', { infer: true }) ?? 5;
|
||||
}
|
||||
|
||||
private clampMinutes(value: number, min: number, max: number): number {
|
||||
if (!Number.isFinite(value)) {
|
||||
return min;
|
||||
}
|
||||
|
||||
return Math.min(max, Math.max(min, Math.floor(value)));
|
||||
}
|
||||
|
||||
private async assertEmailNotUsed(email: string): Promise<void> {
|
||||
const existingUser = await this.usersService.findByEmail(email);
|
||||
if (existingUser) {
|
||||
throw new ConflictException('Email already used');
|
||||
}
|
||||
}
|
||||
|
||||
private assertUsableSignupDraft(
|
||||
draft: Awaited<ReturnType<AuthRepository['findSignupDraftWithSecrets']>>,
|
||||
email: string,
|
||||
): asserts draft is NonNullable<typeof draft> {
|
||||
if (!draft || draft.consumed || draft.email !== email) {
|
||||
throw new BadRequestException('Invalid signup draft');
|
||||
}
|
||||
if (draft.expiresAt <= new Date() || draft.codeExpiresAt <= new Date()) {
|
||||
throw new GoneException('Signup draft expired');
|
||||
}
|
||||
}
|
||||
|
||||
private assertCompletableSignupDraft(
|
||||
draft: Awaited<ReturnType<AuthRepository['findSignupDraftByTokenHash']>>,
|
||||
): asserts draft is NonNullable<typeof draft> {
|
||||
if (!draft || !draft.verifiedAt || !draft.signupTokenExpiresAt) {
|
||||
throw new UnauthorizedException('Invalid or expired signup token');
|
||||
}
|
||||
if (draft.expiresAt <= new Date() || draft.signupTokenExpiresAt <= new Date()) {
|
||||
throw new UnauthorizedException('Invalid or expired signup token');
|
||||
}
|
||||
}
|
||||
|
||||
private getSuperAdminPermissions(): string[] {
|
||||
return [...DEFAULT_SUPERADMIN_PERMISSIONS];
|
||||
}
|
||||
|
||||
109
src/modules/auth/dto/signup-complete.dto.ts
Normal file
109
src/modules/auth/dto/signup-complete.dto.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { Transform, Type } from 'class-transformer';
|
||||
import {
|
||||
IsArray,
|
||||
IsBoolean,
|
||||
IsEnum,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Length,
|
||||
Max,
|
||||
Matches,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
import { ExperienceLevel } from '../../../common/enums/experience-level.enum';
|
||||
import { MusicRole } from '../../../common/enums/music-role.enum';
|
||||
import { toStringArray } from '../../../common/utils/array-transform.util';
|
||||
import { toBoolean } from '../../../common/utils/query-transform.util';
|
||||
|
||||
export class SignupCompleteDto {
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
signupToken!: string;
|
||||
|
||||
@ApiProperty({ required: false, enum: MusicRole, isArray: true })
|
||||
@IsOptional()
|
||||
@Transform(toStringArray)
|
||||
@IsArray()
|
||||
@IsEnum(MusicRole, { each: true })
|
||||
musicRoles?: MusicRole[];
|
||||
|
||||
@ApiProperty({ required: false, type: [String], example: ['Tarab', 'Pop'] })
|
||||
@IsOptional()
|
||||
@Transform(toStringArray)
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
musicGenres?: string[];
|
||||
|
||||
@ApiProperty({ required: false, enum: ExperienceLevel, example: ExperienceLevel.BEGINNER })
|
||||
@IsOptional()
|
||||
@IsEnum(ExperienceLevel)
|
||||
experienceLevel?: ExperienceLevel;
|
||||
|
||||
@ApiProperty({ required: false, type: [String], example: ['Oud', 'Piano'] })
|
||||
@IsOptional()
|
||||
@Transform(toStringArray)
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
favoriteInstruments?: string[];
|
||||
|
||||
@ApiProperty({ required: false, type: [String], example: ['Bayati', 'Rast'] })
|
||||
@IsOptional()
|
||||
@Transform(toStringArray)
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
favoriteMaqamat?: string[];
|
||||
|
||||
@ApiProperty({ required: false, example: 'Artist One' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Length(0, 80)
|
||||
stageName?: string;
|
||||
|
||||
@ApiProperty({ example: 'John Doe' })
|
||||
@IsString()
|
||||
@Length(2, 80)
|
||||
name!: string;
|
||||
|
||||
@ApiProperty({ required: false, example: 'john_doe' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Length(3, 30)
|
||||
@Matches(/^[a-zA-Z0-9_.]+$/, { message: 'username can contain letters, numbers, _ and .' })
|
||||
username?: string;
|
||||
|
||||
@ApiProperty({ required: false, maxLength: 160 })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Length(0, 160)
|
||||
bio?: string;
|
||||
|
||||
@ApiProperty({ required: false, example: 'Riyadh, Saudi Arabia' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Length(0, 120)
|
||||
location?: string;
|
||||
|
||||
@ApiProperty({ required: false, example: 24.7136, minimum: -90, maximum: 90 })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
@Min(-90)
|
||||
@Max(90)
|
||||
latitude?: number;
|
||||
|
||||
@ApiProperty({ required: false, example: 46.6753, minimum: -180, maximum: 180 })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
@Min(-180)
|
||||
@Max(180)
|
||||
longitude?: number;
|
||||
|
||||
@ApiProperty({ required: false, default: false })
|
||||
@IsOptional()
|
||||
@Transform(toBoolean)
|
||||
@IsBoolean()
|
||||
isPrivate?: boolean;
|
||||
}
|
||||
18
src/modules/auth/dto/signup-start.dto.ts
Normal file
18
src/modules/auth/dto/signup-start.dto.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsEmail, IsString, Length } from 'class-validator';
|
||||
|
||||
export class SignupStartDto {
|
||||
@ApiProperty({ example: 'john@example.com' })
|
||||
@IsEmail()
|
||||
email!: string;
|
||||
|
||||
@ApiProperty({ minLength: 8 })
|
||||
@IsString()
|
||||
@Length(8, 64)
|
||||
password!: string;
|
||||
|
||||
@ApiProperty({ minLength: 8 })
|
||||
@IsString()
|
||||
@Length(8, 64)
|
||||
confirmPassword!: string;
|
||||
}
|
||||
17
src/modules/auth/dto/signup-verify.dto.ts
Normal file
17
src/modules/auth/dto/signup-verify.dto.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsEmail, IsString, Length } from 'class-validator';
|
||||
|
||||
export class SignupVerifyDto {
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
signupId!: string;
|
||||
|
||||
@ApiProperty({ example: 'john@example.com' })
|
||||
@IsEmail()
|
||||
email!: string;
|
||||
|
||||
@ApiProperty({ example: '123456' })
|
||||
@IsString()
|
||||
@Length(6, 6)
|
||||
code!: string;
|
||||
}
|
||||
42
src/modules/auth/schemas/signup-draft.schema.ts
Normal file
42
src/modules/auth/schemas/signup-draft.schema.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
|
||||
import { HydratedDocument } from 'mongoose';
|
||||
|
||||
export type SignupDraftDocument = HydratedDocument<SignupDraft>;
|
||||
|
||||
@Schema({ timestamps: true, versionKey: false })
|
||||
export class SignupDraft {
|
||||
@Prop({ required: true, trim: true, lowercase: true, index: true })
|
||||
email!: string;
|
||||
|
||||
@Prop({ required: true, select: false })
|
||||
passwordHash!: string;
|
||||
|
||||
@Prop({ required: true, select: false })
|
||||
codeHash!: string;
|
||||
|
||||
@Prop({ required: true })
|
||||
codeExpiresAt!: Date;
|
||||
|
||||
@Prop({ type: Date, default: null })
|
||||
verifiedAt?: Date | null;
|
||||
|
||||
@Prop({ type: String, default: '', select: false })
|
||||
signupTokenHash!: string;
|
||||
|
||||
@Prop({ type: Date, default: null })
|
||||
signupTokenExpiresAt?: Date | null;
|
||||
|
||||
@Prop({ default: 0, min: 0 })
|
||||
attempts!: number;
|
||||
|
||||
@Prop({ default: false, index: true })
|
||||
consumed!: boolean;
|
||||
|
||||
@Prop({ required: true })
|
||||
expiresAt!: Date;
|
||||
}
|
||||
|
||||
export const SignupDraftSchema = SchemaFactory.createForClass(SignupDraft);
|
||||
|
||||
SignupDraftSchema.index({ email: 1, consumed: 1, expiresAt: -1 });
|
||||
SignupDraftSchema.index({ expiresAt: 1 }, { expireAfterSeconds: 0 });
|
||||
@@ -102,6 +102,36 @@ export class UsersService {
|
||||
});
|
||||
}
|
||||
|
||||
async createWithOptionalAvatarFile(
|
||||
dto: CreateUserDto & { password: string; role?: UserRole },
|
||||
avatarFile?: UploadedImageFile,
|
||||
): Promise<UserDocument> {
|
||||
let uploadedAvatarUrl = '';
|
||||
|
||||
try {
|
||||
if (avatarFile) {
|
||||
uploadedAvatarUrl = await this.saveAvatarFile(avatarFile);
|
||||
}
|
||||
|
||||
return await this.create({
|
||||
...dto,
|
||||
...(uploadedAvatarUrl ? { avatar: uploadedAvatarUrl } : {}),
|
||||
});
|
||||
} catch (error) {
|
||||
if (uploadedAvatarUrl) {
|
||||
await this.deleteManagedUpload(uploadedAvatarUrl);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async rollbackSignupCreatedUser(userId: string, avatarUrl?: string): Promise<void> {
|
||||
await this.usersRepository.deleteById(userId);
|
||||
if (avatarUrl) {
|
||||
await this.deleteManagedUpload(avatarUrl);
|
||||
}
|
||||
}
|
||||
|
||||
async createAdminBySuperAdmin(
|
||||
superAdminIdentifier: string,
|
||||
dto: CreateAdminDto,
|
||||
|
||||
المرجع في مشكلة جديدة
حظر مستخدم