forked from Yara724/api
YARA-1035
This commit is contained in:
21
src/features/auth/auth.controller.ts
Normal file
21
src/features/auth/auth.controller.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { Body, Controller, Post } from "@nestjs/common";
|
||||
import { AuthService } from "./auth.service";
|
||||
import { Public } from "src/common/auth/decorators";
|
||||
import { UserLoginDto, UserRegisterDto } from "../user/dtos";
|
||||
|
||||
@Controller("auth")
|
||||
export class AuthController {
|
||||
constructor(private readonly authService: AuthService) {}
|
||||
|
||||
@Public()
|
||||
@Post("user/send-otp")
|
||||
async register(@Body() userRegisterDto: UserRegisterDto) {
|
||||
return await this.authService.registerUser(userRegisterDto);
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Post("user/login")
|
||||
async login(@Body() userLoginDto: UserLoginDto) {
|
||||
return await this.authService.loginUser(userLoginDto);
|
||||
}
|
||||
}
|
||||
33
src/features/auth/auth.module.ts
Normal file
33
src/features/auth/auth.module.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { APP_GUARD } from "@nestjs/core";
|
||||
import { JwtModule } from "@nestjs/jwt";
|
||||
import { StringValue } from "ms";
|
||||
import { AuthGuard, RolesGuard } from "src/common/auth/guards";
|
||||
import { HashService, OtpService } from "./providers";
|
||||
import { AuthController } from "./auth.controller";
|
||||
import { AuthService } from "./auth.service";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
JwtModule.registerAsync({
|
||||
global: true,
|
||||
inject: [ConfigService],
|
||||
useFactory: (configService: ConfigService) => ({
|
||||
secret: configService.get<string>("JWT_SECRET"),
|
||||
signOptions: {
|
||||
expiresIn: configService.get<StringValue>("JWT_EXPIRY"),
|
||||
},
|
||||
}),
|
||||
}),
|
||||
],
|
||||
controllers: [AuthController],
|
||||
providers: [
|
||||
{ provide: APP_GUARD, useClass: AuthGuard },
|
||||
{ provide: APP_GUARD, useClass: RolesGuard },
|
||||
AuthService,
|
||||
HashService,
|
||||
OtpService,
|
||||
],
|
||||
})
|
||||
export class AuthModule {}
|
||||
98
src/features/auth/auth.service.ts
Normal file
98
src/features/auth/auth.service.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
} from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { UserRepository } from "src/features/user/repositories";
|
||||
import { HashService, OtpService } from "./providers";
|
||||
import { UserLoginDto, UserRegisterDto } from "../user/dtos";
|
||||
import { Otp } from "../user/schemas";
|
||||
import { Environment } from "src/core/config/config.schema";
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
otpDigits: number;
|
||||
otpExpiryMinutes: number;
|
||||
environment: Environment;
|
||||
|
||||
constructor(
|
||||
private readonly userRepo: UserRepository,
|
||||
private readonly hashService: HashService,
|
||||
private readonly configService: ConfigService,
|
||||
private readonly otpService: OtpService,
|
||||
) {
|
||||
this.otpDigits = this.configService.get<number>("DEFAULT_OTP_DIGITS") ?? 5;
|
||||
this.otpExpiryMinutes =
|
||||
(this.configService.get<number>("DEFAULT_OTP_EXPIRY_MINUTES") ?? 2) * 60;
|
||||
this.environment = this.configService.get<Environment>("NODE_ENV");
|
||||
}
|
||||
|
||||
async registerUser(dto: UserRegisterDto) {
|
||||
let user = await this.userRepo.retrieveByCellPhoneNumber(
|
||||
dto.cellphoneNumber,
|
||||
);
|
||||
|
||||
if (user && new Date() < new Date(user.otp.expiresAt))
|
||||
throw new BadRequestException("OTP_STILL_VALID");
|
||||
|
||||
if (!user) {
|
||||
user = await this.userRepo.create(dto.cellphoneNumber);
|
||||
}
|
||||
|
||||
const otpCode = this.otpService.generateOtp(this.otpDigits);
|
||||
const hashedOtp = await this.hashService.hashOtp(otpCode);
|
||||
const otp: Otp = {
|
||||
hash: hashedOtp,
|
||||
expiresAt: new Date(Date.now() + this.otpExpiryMinutes),
|
||||
};
|
||||
|
||||
await this.userRepo.saveOtp(user.cellphoneNumber, otp);
|
||||
|
||||
if (this.environment === "development") {
|
||||
return otpCode;
|
||||
}
|
||||
//TODO: Implement Integrations module and integrate with kavenegar, or whatever the provider is
|
||||
//TODO: Send sms
|
||||
return {};
|
||||
}
|
||||
|
||||
async loginUser(userLoginDto: UserLoginDto) {
|
||||
const user = await this.userRepo.retrieveByCellPhoneNumber(
|
||||
userLoginDto.cellphoneNumber,
|
||||
);
|
||||
if (!user) throw new BadRequestException("USER_NOT_FOUND");
|
||||
|
||||
const storedOtp = (user as any).otp;
|
||||
if (!storedOtp) {
|
||||
throw new BadRequestException("OTP_NOT_REQUESTED");
|
||||
}
|
||||
|
||||
// Check expiry first — don't increment attempts on expired OTP
|
||||
if (new Date() > new Date(storedOtp.expiresAt)) {
|
||||
throw new BadRequestException("OTP_EXPIRED");
|
||||
}
|
||||
|
||||
// Check attempt count before verifying — prevent brute force
|
||||
if (storedOtp.attempts >= this.otpService.maximum_otp_attemps) {
|
||||
throw new ForbiddenException("OTP_MAX_ATTEMPTS_EXCEEDED");
|
||||
}
|
||||
|
||||
const isValid = await this.hashService.verifyOtp(
|
||||
userLoginDto.otp,
|
||||
storedOtp.hash,
|
||||
);
|
||||
|
||||
if (!isValid) {
|
||||
await this.userRepo.incrementOtpAttempts(userLoginDto.cellphoneNumber);
|
||||
throw new BadRequestException("OTP_INVALID");
|
||||
}
|
||||
|
||||
// OTP is correct — clear it so it can't be reused
|
||||
await this.userRepo.clearOtp(userLoginDto.cellphoneNumber);
|
||||
|
||||
//TODO: Generate access+refresh token and send it to user
|
||||
|
||||
return {};
|
||||
}
|
||||
}
|
||||
167
src/features/auth/providers/hash.provider.ts
Normal file
167
src/features/auth/providers/hash.provider.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
import {
|
||||
BinaryLike,
|
||||
randomBytes,
|
||||
scrypt,
|
||||
ScryptOptions,
|
||||
timingSafeEqual,
|
||||
} from "node:crypto";
|
||||
import { promisify } from "node:util";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import {
|
||||
CURRENT_ALGORITHM,
|
||||
CURRENT_VERSION,
|
||||
KEY_LENGTH,
|
||||
KEY_LENGTH_FOR_OTP,
|
||||
SCRYPT_PARAMS,
|
||||
SCRYPT_PARAMS_FOR_OTP,
|
||||
} from "src/common/auth/constants";
|
||||
import { PasswordHash } from "src/common/auth/types";
|
||||
|
||||
const scryptAsync = promisify<
|
||||
BinaryLike,
|
||||
BinaryLike,
|
||||
number,
|
||||
ScryptOptions,
|
||||
Buffer
|
||||
>(scrypt);
|
||||
|
||||
@Injectable()
|
||||
export class HashService {
|
||||
private readonly pepper: string;
|
||||
private readonly current_alogrithm: string;
|
||||
private readonly current_version: number;
|
||||
|
||||
constructor(private readonly configService: ConfigService) {
|
||||
this.pepper = this.configService.get<string>("HASH_PEPPER") ?? "";
|
||||
this.current_alogrithm = CURRENT_ALGORITHM;
|
||||
this.current_version = CURRENT_VERSION;
|
||||
}
|
||||
|
||||
private applyPepper(input: string): string {
|
||||
return this.pepper ? `${input}${this.pepper}` : input;
|
||||
}
|
||||
|
||||
private generateSalt(bytes: number = 16): string {
|
||||
return randomBytes(bytes).toString("hex");
|
||||
}
|
||||
|
||||
private isLegacyPasswordFormat(stored: unknown): stored is string {
|
||||
return typeof stored === "string" && stored.includes(":");
|
||||
}
|
||||
|
||||
private isVersionedPasswordFormat(stored: unknown): stored is PasswordHash {
|
||||
return (
|
||||
typeof stored === "object" &&
|
||||
stored !== null &&
|
||||
"hash" in stored &&
|
||||
"salt" in stored &&
|
||||
"algorithm" in stored &&
|
||||
"version" in stored
|
||||
);
|
||||
}
|
||||
|
||||
private async scrypt(input: string): Promise<[string, string]> {
|
||||
const salt = this.generateSalt();
|
||||
const pepperedInput = this.applyPepper(input);
|
||||
const derived = await scryptAsync(
|
||||
pepperedInput,
|
||||
salt,
|
||||
KEY_LENGTH,
|
||||
SCRYPT_PARAMS,
|
||||
);
|
||||
return [salt, derived.toString("hex")];
|
||||
}
|
||||
|
||||
private async verifyScrypt(
|
||||
password: string,
|
||||
salt: string,
|
||||
hashHex: string,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const pepperedInput = this.applyPepper(password);
|
||||
const derived = await scryptAsync(
|
||||
pepperedInput,
|
||||
salt,
|
||||
KEY_LENGTH,
|
||||
SCRYPT_PARAMS,
|
||||
);
|
||||
const storedBuffer = Buffer.from(hashHex, "hex");
|
||||
if (derived.length !== storedBuffer.length) return false;
|
||||
return timingSafeEqual(derived, storedBuffer);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async needsRehash(stored: string | PasswordHash): Promise<boolean> {
|
||||
if (this.isLegacyPasswordFormat(stored)) return true;
|
||||
if (!this.isVersionedPasswordFormat(stored)) return true;
|
||||
|
||||
return (
|
||||
stored.algorithm !== this.current_alogrithm ||
|
||||
stored.version !== this.current_version
|
||||
);
|
||||
}
|
||||
|
||||
async hashPassword(password: string): Promise<PasswordHash> {
|
||||
const [salt, hash] = await this.scrypt(password);
|
||||
|
||||
return {
|
||||
hash,
|
||||
salt,
|
||||
algorithm: CURRENT_ALGORITHM,
|
||||
version: CURRENT_VERSION,
|
||||
params: { ...SCRYPT_PARAMS, keyLength: KEY_LENGTH },
|
||||
createdAt: new Date(),
|
||||
};
|
||||
}
|
||||
|
||||
async verifyPassword(
|
||||
password: string,
|
||||
stored: string | PasswordHash,
|
||||
): Promise<boolean> {
|
||||
if (this.isLegacyPasswordFormat(stored)) {
|
||||
const [salt, hash] = stored.split(":");
|
||||
return this.verifyScrypt(password, salt, hash);
|
||||
}
|
||||
|
||||
if (!this.isVersionedPasswordFormat(stored)) return false;
|
||||
|
||||
switch (stored.algorithm) {
|
||||
case "scrypt":
|
||||
return this.verifyScrypt(password, stored.salt, stored.hash);
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async hashOtp(otp: string): Promise<string> {
|
||||
const salt = this.generateSalt(8);
|
||||
const derived = await scryptAsync(
|
||||
otp,
|
||||
salt,
|
||||
KEY_LENGTH_FOR_OTP,
|
||||
SCRYPT_PARAMS_FOR_OTP,
|
||||
);
|
||||
return `${salt}:${derived.toString("hex")}`;
|
||||
}
|
||||
|
||||
async verifyOtp(otp: string, stored: string): Promise<boolean> {
|
||||
const [salt, hashHex] = stored.split(":");
|
||||
if (!salt || !hashHex) return false;
|
||||
try {
|
||||
const derived = await scryptAsync(
|
||||
otp,
|
||||
salt,
|
||||
KEY_LENGTH_FOR_OTP,
|
||||
SCRYPT_PARAMS_FOR_OTP,
|
||||
);
|
||||
const storedBuffer = Buffer.from(hashHex, "hex");
|
||||
if (derived.length !== storedBuffer.length) return false;
|
||||
return timingSafeEqual(derived, storedBuffer);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
2
src/features/auth/providers/index.ts
Normal file
2
src/features/auth/providers/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { HashService } from "./hash.provider";
|
||||
export { OtpService } from "./otp.provider";
|
||||
22
src/features/auth/providers/otp.provider.ts
Normal file
22
src/features/auth/providers/otp.provider.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { Environment } from "src/core/config/config.schema";
|
||||
|
||||
@Injectable()
|
||||
export class OtpService {
|
||||
maximum_otp_attemps: number;
|
||||
environment: Environment;
|
||||
|
||||
constructor(private readonly configService: ConfigService) {
|
||||
this.maximum_otp_attemps =
|
||||
this.configService.get<number>("MAX_OTP_ATTEMPTS");
|
||||
this.environment = this.configService.get<Environment>("NODE_ENV");
|
||||
}
|
||||
|
||||
generateOtp(digits: number): string {
|
||||
if (this.environment === "development") {
|
||||
return this.configService.get<string>("DEFAULT_MOCK_OTP") ?? "11111";
|
||||
}
|
||||
return String(Math.floor(Math.random() * 1_000_000)).padStart(digits, "0");
|
||||
}
|
||||
}
|
||||
32
src/features/expert-initiated/dtos/login.dto.ts
Normal file
32
src/features/expert-initiated/dtos/login.dto.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { IsEmail, IsNotEmpty, IsString } from "class-validator";
|
||||
|
||||
export class FieldExpertLoginDto {
|
||||
@ApiProperty()
|
||||
@IsEmail({ allow_underscores: true })
|
||||
@IsString({
|
||||
message: "email is not string",
|
||||
context: {
|
||||
errorCode: 4000,
|
||||
note: "The validated email type must be string",
|
||||
},
|
||||
})
|
||||
readonly email: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsNotEmpty({
|
||||
message: "password is empty",
|
||||
context: {
|
||||
errorCode: 4001,
|
||||
note: "The validated password must not be empty",
|
||||
},
|
||||
})
|
||||
@IsString({
|
||||
message: "password is not string",
|
||||
context: {
|
||||
errorCode: 4002,
|
||||
note: "The validated password type must be string",
|
||||
},
|
||||
})
|
||||
readonly password: string;
|
||||
}
|
||||
16
src/features/expert-initiated/expert-initiated.controller.ts
Normal file
16
src/features/expert-initiated/expert-initiated.controller.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Body, Controller, Post } from "@nestjs/common";
|
||||
import { ApiBearerAuth, ApiTags } from "@nestjs/swagger";
|
||||
import { FieldExpertLoginDto } from "./dtos/login.dto";
|
||||
import { FieldExpertService } from "./expert-initiated.service";
|
||||
|
||||
@Controller("expert-initiated")
|
||||
@ApiTags("Expert Initiated Flow (Field Expert)")
|
||||
@ApiBearerAuth()
|
||||
export class FieldExpertController {
|
||||
constructor(private readonly fieldExpertService: FieldExpertService) {}
|
||||
|
||||
@Post("login")
|
||||
async login(@Body() fieldExpertLoginDto: FieldExpertLoginDto) {
|
||||
return await this.fieldExpertService.login(fieldExpertLoginDto);
|
||||
}
|
||||
}
|
||||
25
src/features/expert-initiated/expert-initiated.module.ts
Normal file
25
src/features/expert-initiated/expert-initiated.module.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { MongooseModule } from "@nestjs/mongoose";
|
||||
import { FieldExpert, FieldExpertSchema } from "./schemas/field-expert.schema";
|
||||
import { FieldExpertController } from "./expert-initiated.controller";
|
||||
import { FieldExpertService } from "./expert-initiated.service";
|
||||
import { FieldExpertRepository } from "./repositories/field-expert.repository";
|
||||
import { AuthModule } from "src/auth/auth.module";
|
||||
import { JwtModule } from "@nestjs/jwt";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
MongooseModule.forFeature([
|
||||
{
|
||||
name: FieldExpert.name,
|
||||
schema: FieldExpertSchema,
|
||||
},
|
||||
]),
|
||||
AuthModule,
|
||||
JwtModule,
|
||||
],
|
||||
controllers: [FieldExpertController],
|
||||
providers: [FieldExpertRepository, FieldExpertService],
|
||||
exports: [],
|
||||
})
|
||||
export class ExpertInitiatedModule {}
|
||||
32
src/features/expert-initiated/expert-initiated.service.ts
Normal file
32
src/features/expert-initiated/expert-initiated.service.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from "@nestjs/common";
|
||||
import { FieldExpertRepository } from "./repositories/field-expert.repository";
|
||||
import { FieldExpertLoginDto } from "./dtos/login.dto";
|
||||
import { UserAuthService } from "src/auth/auth-services/user.auth.service";
|
||||
|
||||
@Injectable()
|
||||
export class FieldExpertService {
|
||||
constructor(
|
||||
private readonly fieldExpertRepository: FieldExpertRepository,
|
||||
private readonly authService: UserAuthService,
|
||||
) {}
|
||||
|
||||
async login(fieldExpertLoginDto: FieldExpertLoginDto) {
|
||||
const user = await this.fieldExpertRepository.retrieveByEmail(
|
||||
fieldExpertLoginDto.email,
|
||||
);
|
||||
|
||||
if (!user) throw new NotFoundException("User not found");
|
||||
|
||||
const validate = await this.authService.validateUser(
|
||||
user.email,
|
||||
user.password,
|
||||
);
|
||||
|
||||
if (!validate)
|
||||
throw new BadRequestException("Username/Password does not match");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { InjectModel } from "@nestjs/mongoose";
|
||||
import { Model } from "mongoose";
|
||||
import { FieldExpert } from "../schemas/field-expert.schema";
|
||||
|
||||
@Injectable()
|
||||
export class FieldExpertRepository {
|
||||
constructor(
|
||||
@InjectModel(FieldExpert.name)
|
||||
private readonly fieldExpertModel: Model<FieldExpert>,
|
||||
) {}
|
||||
|
||||
async retrieveById(id: string): Promise<FieldExpert> {
|
||||
return await this.fieldExpertModel.findById(id).exec();
|
||||
}
|
||||
|
||||
async retrieveByEmail(email: string): Promise<FieldExpert> {
|
||||
return await this.fieldExpertModel.findOne({
|
||||
email,
|
||||
});
|
||||
}
|
||||
}
|
||||
27
src/features/expert-initiated/schemas/field-expert.schema.ts
Normal file
27
src/features/expert-initiated/schemas/field-expert.schema.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose";
|
||||
import { HydratedDocument, Types } from "mongoose";
|
||||
|
||||
export type FieldExpertDocument = HydratedDocument<FieldExpert>;
|
||||
|
||||
@Schema({
|
||||
id: true,
|
||||
timestamps: true,
|
||||
})
|
||||
export class FieldExpert {
|
||||
@Prop({ unique: true })
|
||||
email: string;
|
||||
|
||||
@Prop({ required: true })
|
||||
password: string;
|
||||
|
||||
@Prop({ required: true })
|
||||
firstName: string;
|
||||
|
||||
@Prop({ required: true })
|
||||
lastName: string;
|
||||
|
||||
@Prop({ required: true })
|
||||
cellphone: string;
|
||||
}
|
||||
|
||||
export const FieldExpertSchema = SchemaFactory.createForClass(FieldExpert);
|
||||
2
src/features/user/dtos/index.ts
Normal file
2
src/features/user/dtos/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { UserRegisterDto } from "./requests/user-register.dto";
|
||||
export { UserLoginDto } from "./requests/user-login.dto";
|
||||
16
src/features/user/dtos/requests/user-login.dto.ts
Normal file
16
src/features/user/dtos/requests/user-login.dto.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import {
|
||||
IsMobilePhone,
|
||||
IsNumberString,
|
||||
IsString,
|
||||
Length,
|
||||
} from "class-validator";
|
||||
|
||||
export class UserLoginDto {
|
||||
@IsString({ message: "Cellphone number must be string" })
|
||||
@IsMobilePhone("fa-IR")
|
||||
cellphoneNumber: string;
|
||||
|
||||
@IsNumberString()
|
||||
@Length(4, 6)
|
||||
otp: string;
|
||||
}
|
||||
7
src/features/user/dtos/requests/user-register.dto.ts
Normal file
7
src/features/user/dtos/requests/user-register.dto.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { IsMobilePhone, IsString } from "class-validator";
|
||||
|
||||
export class UserRegisterDto {
|
||||
@IsString({ message: "Cellphone number must be string" })
|
||||
@IsMobilePhone("fa-IR")
|
||||
cellphoneNumber: string;
|
||||
}
|
||||
Reference in New Issue
Block a user