From e6572648074de81f239a75ae3ecabdcea9e198f7 Mon Sep 17 00:00:00 2001 From: radmehr Date: Wed, 18 Feb 2026 09:13:23 +0330 Subject: [PATCH] feat: add simple login logic --- src/app.module.ts | 14 +++--- src/i18n/en/auth.json | 6 +++ src/i18n/fa/auth.json | 6 +++ src/i18n/fa/validation.json | 3 +- src/modules/auth/auth.controller.ts | 19 ++++++++ src/modules/auth/auth.module.ts | 11 +++++ src/modules/auth/dtos/login.dto.ts | 41 +++++++++++++++++ .../auth/http/login.post.endpoints.http | 7 +++ src/modules/auth/providers/auth.service.ts | 45 +++++++++++++++++++ src/modules/otp/otp.entity.ts | 45 +++++++++++++++++++ src/modules/otp/otp.module.ts | 10 +++++ src/modules/otp/providers/otp.service.ts | 39 ++++++++++++++++ src/modules/users/dto/create-user.dto.ts | 2 +- src/modules/users/providers/users.service.ts | 11 +++++ src/modules/users/user.entity.ts | 12 ++--- src/modules/users/users.module.ts | 1 + 16 files changed, 259 insertions(+), 13 deletions(-) create mode 100644 src/i18n/en/auth.json create mode 100644 src/i18n/fa/auth.json create mode 100644 src/modules/auth/auth.controller.ts create mode 100644 src/modules/auth/auth.module.ts create mode 100644 src/modules/auth/dtos/login.dto.ts create mode 100644 src/modules/auth/http/login.post.endpoints.http create mode 100644 src/modules/auth/providers/auth.service.ts create mode 100644 src/modules/otp/otp.entity.ts create mode 100644 src/modules/otp/otp.module.ts create mode 100644 src/modules/otp/providers/otp.service.ts diff --git a/src/app.module.ts b/src/app.module.ts index c1793f8..41ed411 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -6,17 +6,14 @@ import environmentValidation from './config/environment.validation'; import { TypeOrmModule } from '@nestjs/typeorm'; import databaseConfig from './config/database.config'; import { HeaderResolver, I18nJsonLoader, I18nModule } from 'nestjs-i18n'; +import { AuthModule } from './modules/auth/auth.module'; +import { OtpModule } from './modules/otp/otp.module'; import path from 'path'; const ENV = process.env.NODE_ENV; @Module({ imports: [ - /** - * Local Modules - */ - UsersModule, - /** * Third-party Modules */ @@ -55,6 +52,13 @@ const ENV = process.env.NODE_ENV; synchronize: configService.get('database.synchronize'), }), }), + + /** + * Local Modules + */ + UsersModule, + AuthModule, + OtpModule, ], }) export class AppModule {} diff --git a/src/i18n/en/auth.json b/src/i18n/en/auth.json new file mode 100644 index 0000000..50883bc --- /dev/null +++ b/src/i18n/en/auth.json @@ -0,0 +1,6 @@ +{ + "fields": { + "phone": "Phone", + "password": "Password" + } +} diff --git a/src/i18n/fa/auth.json b/src/i18n/fa/auth.json new file mode 100644 index 0000000..330c929 --- /dev/null +++ b/src/i18n/fa/auth.json @@ -0,0 +1,6 @@ +{ + "fields": { + "phone": "شماره موبایل", + "password": "رمز عبور" + } +} diff --git a/src/i18n/fa/validation.json b/src/i18n/fa/validation.json index 90a1870..2ad4eb4 100644 --- a/src/i18n/fa/validation.json +++ b/src/i18n/fa/validation.json @@ -2,5 +2,6 @@ "requiredField": "{field} اجباری است.", "minLengthField": "{field} باید حداقل شامل {min} کاراکتر باشد.", "maxLengthField": "{field} باید حداکثر شامل {max} کاراکتر باشد.", - "wrongField": "{field} معتبر نیست." + "wrongField": "{field} معتبر نیست.", + "wrongFieldFormat": "فرمت {field} معتبر نیست." } diff --git a/src/modules/auth/auth.controller.ts b/src/modules/auth/auth.controller.ts new file mode 100644 index 0000000..f63c3b7 --- /dev/null +++ b/src/modules/auth/auth.controller.ts @@ -0,0 +1,19 @@ +import { Body, Controller, HttpCode, Post } from '@nestjs/common'; +import { LoginDTO } from './dtos/login.dto'; +import { AuthService } from './providers/auth.service'; + +@Controller('users') +export class AuthController { + constructor( + /** + * Inject Auth Service + */ + private readonly authService: AuthService, + ) {} + + @Post('login') + @HttpCode(200) + public async logIn(@Body() loginDto: LoginDTO) { + return await this.authService.logIn(loginDto); + } +} diff --git a/src/modules/auth/auth.module.ts b/src/modules/auth/auth.module.ts new file mode 100644 index 0000000..0a7965a --- /dev/null +++ b/src/modules/auth/auth.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { AuthController } from './auth.controller'; +import { AuthService } from './providers/auth.service'; +import { UsersModule } from '../users/users.module'; + +@Module({ + imports: [UsersModule], + controllers: [AuthController], + providers: [AuthService], +}) +export class AuthModule {} diff --git a/src/modules/auth/dtos/login.dto.ts b/src/modules/auth/dtos/login.dto.ts new file mode 100644 index 0000000..1e1d7b0 --- /dev/null +++ b/src/modules/auth/dtos/login.dto.ts @@ -0,0 +1,41 @@ +import Utils from '@/common/utils'; +import { ApiProperty } from '@nestjs/swagger'; +import { Transform } from 'class-transformer'; +import { IsNotEmpty, IsOptional, IsString, Matches } from 'class-validator'; +import { i18nValidationMessage as t } from 'nestjs-i18n'; + +export class LoginDTO { + @ApiProperty({ + type: 'string', + description: `User's Phone Number`, + example: '09121111111', + required: true, + }) + @IsString() + @IsNotEmpty({ + message: t('validation.requiredField', { + field: '$t(auth.fields.phone)', + }), + }) + @Matches(/^(?:09|9)[0-9]{9}$/, { + message: t('validation.wrongField', { + field: '$t(auth.fields.phone)', + }), + }) + @Transform(({ value }) => Utils.StringUtils.normalizePhone(value as string)) + phone: string; + + @ApiProperty({ + type: 'string', + description: 'Password for User Account', + example: 'Password1@', + required: false, + }) + @IsOptional() + @IsString({ + message: t('validation.wrongFieldFormat', { + field: '$t(auth.fields.password)', + }), + }) + password?: string; +} diff --git a/src/modules/auth/http/login.post.endpoints.http b/src/modules/auth/http/login.post.endpoints.http new file mode 100644 index 0000000..6d4f304 --- /dev/null +++ b/src/modules/auth/http/login.post.endpoints.http @@ -0,0 +1,7 @@ +POST http://localhost:3000/users/login +Content-Type: application/json + +{ + "phone": "09333026363", + "password": "rad1.3.5" +} \ No newline at end of file diff --git a/src/modules/auth/providers/auth.service.ts b/src/modules/auth/providers/auth.service.ts new file mode 100644 index 0000000..350cdec --- /dev/null +++ b/src/modules/auth/providers/auth.service.ts @@ -0,0 +1,45 @@ +import { UsersService } from '@/modules/users/providers/users.service'; +import { Injectable, UnauthorizedException } from '@nestjs/common'; +import { LoginDTO } from '../dtos/login.dto'; + +@Injectable() +export class AuthService { + constructor( + /** + * Inject Users Service + */ + private readonly usersService: UsersService, + ) {} + + public async logIn(loginDto: LoginDTO) { + const { phone, password } = loginDto; + const user = await this.usersService.findOneByPhone(phone); + + if (!user) { + // TODO: Send OTP + + return { + newUser: true, + message: `Otp sent to ${phone}`, + }; + } + + if (!password) { + return { + newUser: false, + message: 'Please send password', + }; + } + + // TODO: Hash password and compare + if (password === user.password) { + // TODO: Create JWT + + return { access: 'ACCESS_TOKEN', refresh: 'REFRESH_TOKEN' }; + } + + throw new UnauthorizedException( + 'Entered phone number or password is wrong.', + ); + } +} diff --git a/src/modules/otp/otp.entity.ts b/src/modules/otp/otp.entity.ts new file mode 100644 index 0000000..04267b9 --- /dev/null +++ b/src/modules/otp/otp.entity.ts @@ -0,0 +1,45 @@ +import { + BeforeInsert, + Column, + CreateDateColumn, + Entity, + Index, + PrimaryGeneratedColumn, +} from 'typeorm'; + +@Entity() +@Index(['phone', 'used']) +export class OTP { + @PrimaryGeneratedColumn() + id: number; + + @Index() + @Column({ + type: 'varchar', + length: 11, + nullable: false, + }) + phone: string; + + @Column({ + type: 'varchar', + length: 5, + nullable: false, + }) + code: string; + + @CreateDateColumn() + createdAt: Date; + + @Index() + @Column({ type: 'timestamptz' }) + expiredAt: Date; + + @Column({ type: 'boolean', default: false }) + used: boolean; + + @BeforeInsert() + setExpiry() { + this.expiredAt = new Date(Date.now() + 2 * 60 * 1000); + } +} diff --git a/src/modules/otp/otp.module.ts b/src/modules/otp/otp.module.ts new file mode 100644 index 0000000..7b91cb3 --- /dev/null +++ b/src/modules/otp/otp.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { OtpService } from './providers/otp.service'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { OTP } from './otp.entity'; + +@Module({ + imports: [TypeOrmModule.forFeature([OTP])], + providers: [OtpService], +}) +export class OtpModule {} diff --git a/src/modules/otp/providers/otp.service.ts b/src/modules/otp/providers/otp.service.ts new file mode 100644 index 0000000..343e000 --- /dev/null +++ b/src/modules/otp/providers/otp.service.ts @@ -0,0 +1,39 @@ +import { Injectable } from '@nestjs/common'; +import { Repository } from 'typeorm'; +import { OTP } from '../otp.entity'; +import { InjectRepository } from '@nestjs/typeorm'; + +@Injectable() +export class OtpService { + constructor( + /** + * Inject OTP Repository + */ + @InjectRepository(OTP) + private readonly otpRepository: Repository, + ) {} + + /** + * Generate OTP 5-digit code + */ + private generateOTPCode(): string { + return Math.floor(10_000 + Math.random() * 90_000).toString(); + } + + /** + * Create new OTP record + */ + public async createOTP(phone: string): Promise { + // OTP request Rate limit + const lastOTP = await this.otpRepository.findOne({ + where: { phone }, + order: { createdAt: 'DESC' }, + }); + + if (lastOTP && Date.now() - lastOTP.createdAt.getTime() < 60_000) { + // throw new TooMany(); + } + + return ''; + } +} diff --git a/src/modules/users/dto/create-user.dto.ts b/src/modules/users/dto/create-user.dto.ts index 512f4d9..bd582f3 100644 --- a/src/modules/users/dto/create-user.dto.ts +++ b/src/modules/users/dto/create-user.dto.ts @@ -104,7 +104,7 @@ export class CreateUserDTO { @MaxLength(96, { message: t('validation.maxLengthField', { field: '$t(users.fields.password)', - min: 96, + max: 96, }), }) @Matches(/^(?=.*[A-Za-z])(?=.*\d)(?=.*[^A-Za-z\d]).{8,}$/, { diff --git a/src/modules/users/providers/users.service.ts b/src/modules/users/providers/users.service.ts index df88b6a..ae236c6 100644 --- a/src/modules/users/providers/users.service.ts +++ b/src/modules/users/providers/users.service.ts @@ -14,6 +14,17 @@ export class UsersService { private readonly userRepository: Repository, ) {} + public async findOneByPhone(phone: string) { + try { + return await this.userRepository.findOneBy({ phone }); + } catch (err) { + throw new RequestTimeoutException( + 'Unable to process your request at the moment. Please try again later.', + { cause: err, description: 'Error connecting to the database' }, + ); + } + } + public async createOne(createUserDto: CreateUserDTO) { const newUser = this.userRepository.create(createUserDto); diff --git a/src/modules/users/user.entity.ts b/src/modules/users/user.entity.ts index 1038fb7..6a57458 100644 --- a/src/modules/users/user.entity.ts +++ b/src/modules/users/user.entity.ts @@ -14,16 +14,16 @@ export class User { @Column({ type: 'varchar', length: 96, - nullable: false, + nullable: true, }) - firstName: string; + firstName?: string; @Column({ type: 'varchar', length: 96, - nullable: false, + nullable: true, }) - lastName: string; + lastName?: string; @Column({ type: 'varchar', @@ -36,9 +36,9 @@ export class User { @Column({ type: 'varchar', length: 96, - nullable: false, + nullable: true, }) - password: string; + password?: string; @CreateDateColumn() createdAt: Date; diff --git a/src/modules/users/users.module.ts b/src/modules/users/users.module.ts index eaea527..59f571e 100644 --- a/src/modules/users/users.module.ts +++ b/src/modules/users/users.module.ts @@ -8,5 +8,6 @@ import { User } from './user.entity'; imports: [TypeOrmModule.forFeature([User])], controllers: [UsersController], providers: [UsersService], + exports: [UsersService], }) export class UsersModule {}