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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user