1
0
forked from Yara724/api

YARA-1035

This commit is contained in:
SepehrYahyaee
2026-06-20 14:51:01 +03:30
parent 2e4b10455b
commit 4fabed77e5
27 changed files with 1329 additions and 74 deletions

View 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);
}
}

View 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 {}

View 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 {};
}
}

View 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;
}
}
}

View File

@@ -0,0 +1,2 @@
export { HashService } from "./hash.provider";
export { OtpService } from "./otp.provider";

View 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");
}
}

View 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;
}

View 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);
}
}

View 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 {}

View 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");
}
}

View File

@@ -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,
});
}
}

View 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);

View File

@@ -0,0 +1,2 @@
export { UserRegisterDto } from "./requests/user-register.dto";
export { UserLoginDto } from "./requests/user-login.dto";

View 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;
}

View 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;
}