1
0
forked from Yara724/api

Added linkToken and linkContext to OTP

This commit is contained in:
SepehrYahyaee
2026-05-13 09:23:45 +03:30
parent e26c533a52
commit 5b6409fc2e
9 changed files with 344 additions and 28 deletions

View File

@@ -26,7 +26,10 @@ export class UserAuthController {
}) })
@ApiAcceptedResponse() @ApiAcceptedResponse()
async sendOtpRq(@Body() body: UserLoginDto) { async sendOtpRq(@Body() body: UserLoginDto) {
const res = await this.userAuthService.sendOtpRequest(body.mobile); const res = await this.userAuthService.sendOtpRequest(body.mobile, {
linkToken: body.linkToken,
linkContext: body.linkContext,
});
if (res) { if (res) {
throw new HttpException(res, HttpStatus.ACCEPTED); throw new HttpException(res, HttpStatus.ACCEPTED);
} }

View File

@@ -0,0 +1,46 @@
import {
BadRequestException,
ForbiddenException,
UnauthorizedException,
} from "@nestjs/common";
export enum UserAuthErrorCode {
USER_NOT_FOUND = "USER_NOT_FOUND",
OTP_REQUIRED = "OTP_REQUIRED",
OTP_EXPIRED = "OTP_EXPIRED",
OTP_INVALID = "OTP_INVALID",
OTP_REQUEST_TOO_SOON = "OTP_REQUEST_TOO_SOON",
LINK_NOT_FOUND = "LINK_NOT_FOUND",
LINK_MOBILE_MISMATCH = "LINK_MOBILE_MISMATCH",
}
const messages: Record<UserAuthErrorCode, string> = {
[UserAuthErrorCode.USER_NOT_FOUND]: "User not found",
[UserAuthErrorCode.OTP_REQUIRED]: "Please request an OTP first",
[UserAuthErrorCode.OTP_EXPIRED]: "OTP has expired",
[UserAuthErrorCode.OTP_INVALID]: "OTP is invalid",
[UserAuthErrorCode.OTP_REQUEST_TOO_SOON]:
"Wait for expiry time to finish before requesting another OTP",
[UserAuthErrorCode.LINK_NOT_FOUND]: "Linked SMS token was not found",
[UserAuthErrorCode.LINK_MOBILE_MISMATCH]:
"This mobile number is not allowed to use this SMS link",
};
export function userAuthErrorBody(code: UserAuthErrorCode) {
return {
code,
message: messages[code],
};
}
export function throwUserAuthError(code: UserAuthErrorCode): never {
if (code === UserAuthErrorCode.OTP_REQUEST_TOO_SOON) {
throw new BadRequestException(userAuthErrorBody(code));
}
if (code === UserAuthErrorCode.LINK_MOBILE_MISMATCH) {
throw new ForbiddenException(userAuthErrorBody(code));
}
throw new UnauthorizedException(userAuthErrorBody(code));
}

View File

@@ -0,0 +1,187 @@
import { Injectable } from "@nestjs/common";
import { InjectModel } from "@nestjs/mongoose";
import { Model, Types } from "mongoose";
import {
UserAuthErrorCode,
throwUserAuthError,
} from "src/auth/auth-services/user-auth-error";
import { ClaimRequestManagementModel } from "src/claim-request-management/entites/schema/claim-request-management.schema";
import { BlameRequest } from "src/request-management/entities/schema/blame-cases.schema";
import { PartyRole } from "src/request-management/entities/schema/partyRole.enum";
import { RequestManagementModel } from "src/request-management/entities/schema/request-management.schema";
import { UserDbService } from "src/users/entities/db-service/user.db.service";
@Injectable()
export class UserLinkAccessService {
constructor(
@InjectModel(RequestManagementModel.name)
private readonly requestManagementModel: Model<RequestManagementModel>,
@InjectModel(BlameRequest.name)
private readonly blameRequestModel: Model<BlameRequest>,
@InjectModel(ClaimRequestManagementModel.name)
private readonly claimRequestManagementModel: Model<ClaimRequestManagementModel>,
private readonly userDbService: UserDbService,
) {}
async assertMobileAllowed(params: {
mobile: string;
linkToken?: string;
linkContext?: string;
}): Promise<void> {
const linkToken = params.linkToken?.trim();
if (!linkToken) return;
const allowedMobiles = await this.resolveAllowedMobiles(
linkToken,
params.linkContext,
);
if (allowedMobiles.length === 0) {
throwUserAuthError(UserAuthErrorCode.LINK_NOT_FOUND);
}
const normalizedMobile = this.normalizePhone(params.mobile);
if (
!normalizedMobile ||
!allowedMobiles.some((mobile) => this.normalizePhone(mobile) === normalizedMobile)
) {
throwUserAuthError(UserAuthErrorCode.LINK_MOBILE_MISMATCH);
}
}
async resolveAllowedMobiles(
linkToken: string,
linkContext?: string,
): Promise<string[]> {
if (!Types.ObjectId.isValid(linkToken)) return [];
const id = new Types.ObjectId(linkToken);
const context = this.normalizeContext(linkContext);
const allowedMobiles = new Set<string>();
const [legacyRequest, blameRequest, claimRequest] = await Promise.all([
this.requestManagementModel.findById(id).lean().exec(),
this.blameRequestModel.findById(id).lean().exec(),
this.claimRequestManagementModel.findById(id).lean().exec(),
]);
this.addLegacyRequestPhones(allowedMobiles, legacyRequest, context);
this.addBlameRequestPhones(allowedMobiles, blameRequest, context);
await this.addClaimOwnerPhone(allowedMobiles, claimRequest);
return Array.from(allowedMobiles);
}
private addLegacyRequestPhones(
allowedMobiles: Set<string>,
legacyRequest: any,
context?: string,
) {
if (!legacyRequest) return;
const shouldAddFirst = !context || this.isFirstContext(context);
const shouldAddSecond = !context || this.isSecondContext(context);
if (shouldAddFirst) {
this.addPhone(
allowedMobiles,
legacyRequest.firstPartyDetails?.firstPartyPhoneNumber,
);
}
if (shouldAddSecond) {
this.addPhone(
allowedMobiles,
legacyRequest.secondPartyDetails?.secondPartyPhoneNumber,
);
}
for (const event of legacyRequest.history || []) {
const metadata = event?.metadata;
if (shouldAddSecond) this.addPhone(allowedMobiles, metadata?.secondPartyPhone);
for (const sent of metadata?.sentTo || []) {
if (!context || this.matchesRoleContext(context, sent?.role)) {
this.addPhone(allowedMobiles, sent?.phoneNumber);
}
}
}
}
private addBlameRequestPhones(
allowedMobiles: Set<string>,
blameRequest: any,
context?: string,
) {
if (!blameRequest) return;
for (const party of blameRequest.parties || []) {
if (context && !this.matchesRoleContext(context, party?.role)) continue;
this.addPhone(allowedMobiles, party?.person?.phoneNumber);
}
}
private async addClaimOwnerPhone(
allowedMobiles: Set<string>,
claimRequest: any,
) {
if (!claimRequest) return;
const ownerUserId = claimRequest.owner?.userId || claimRequest.userId;
if (!ownerUserId) return;
const ownerUserIdText = String(ownerUserId);
if (claimRequest.blameRequestId) {
const blameRequest = await this.blameRequestModel
.findById(claimRequest.blameRequestId)
.lean()
.exec();
const ownerParty = (blameRequest?.parties || []).find(
(party: any) =>
party?.person?.userId && String(party.person.userId) === ownerUserIdText,
);
this.addPhone(allowedMobiles, ownerParty?.person?.phoneNumber);
}
if (Types.ObjectId.isValid(ownerUserIdText)) {
const user = await this.userDbService.findOne({
_id: new Types.ObjectId(ownerUserIdText),
});
this.addPhone(allowedMobiles, user?.mobile);
}
}
private addPhone(allowedMobiles: Set<string>, phone?: string) {
const normalized = this.normalizePhone(phone);
if (normalized) allowedMobiles.add(normalized);
}
private normalizePhone(phone?: string): string | undefined {
if (!phone) return undefined;
const digits = String(phone).replace(/\D/g, "");
if (!digits) return undefined;
if (digits.startsWith("0098")) return `0${digits.slice(4)}`;
if (digits.startsWith("98") && digits.length === 12) {
return `0${digits.slice(2)}`;
}
if (digits.length === 10 && digits.startsWith("9")) return `0${digits}`;
return digits;
}
private normalizeContext(linkContext?: string): string | undefined {
return linkContext?.trim().toUpperCase();
}
private matchesRoleContext(context: string, role?: string): boolean {
if (this.isFirstContext(context)) return role === PartyRole.FIRST;
if (this.isSecondContext(context)) return role === PartyRole.SECOND;
return true;
}
private isFirstContext(context: string): boolean {
return ["FIRST", "USER", "USER1", "FIRST_PARTY"].includes(context);
}
private isSecondContext(context: string): boolean {
return ["SECOND", "USER2", "SECOND_PARTY"].includes(context);
}
}

View File

@@ -1,20 +1,22 @@
import { import { HttpException, HttpStatus, Injectable, Logger } from "@nestjs/common";
BadRequestException,
HttpException,
HttpStatus,
Injectable,
Logger,
NotAcceptableException,
NotFoundException,
} from "@nestjs/common";
import { JwtService } from "@nestjs/jwt"; import { JwtService } from "@nestjs/jwt";
import { Types } from "mongoose"; import { Types } from "mongoose";
import {
UserAuthErrorCode,
throwUserAuthError,
} from "src/auth/auth-services/user-auth-error";
import { UserLinkAccessService } from "src/auth/auth-services/user-link-access.service";
import { LoginDtoRs } from "src/auth/dto/user/login.dto"; import { LoginDtoRs } from "src/auth/dto/user/login.dto";
import { OtpGeneratorService } from "src/sms-orchestration/otp-generator.service"; import { OtpGeneratorService } from "src/sms-orchestration/otp-generator.service";
import { UserDbService } from "src/users/entities/db-service/user.db.service"; import { UserDbService } from "src/users/entities/db-service/user.db.service";
import { SmsOrchestrationService } from "src/sms-orchestration/sms-orchestration.service"; import { SmsOrchestrationService } from "src/sms-orchestration/sms-orchestration.service";
import { HashService } from "src/utils/hash/hash.service"; import { HashService } from "src/utils/hash/hash.service";
export interface LinkBinding {
linkToken?: string;
linkContext?: string;
}
// TODO FIX REGISTER TO USER.SERVICE AND AUTH IN THIS MODULE // TODO FIX REGISTER TO USER.SERVICE AND AUTH IN THIS MODULE
@Injectable() @Injectable()
export class UserAuthService { export class UserAuthService {
@@ -26,16 +28,27 @@ export class UserAuthService {
private readonly hashService: HashService, private readonly hashService: HashService,
private readonly otpCreator: OtpGeneratorService, private readonly otpCreator: OtpGeneratorService,
private readonly smsOrchestrationService: SmsOrchestrationService, private readonly smsOrchestrationService: SmsOrchestrationService,
private readonly userLinkAccessService: UserLinkAccessService,
) {} ) {}
async validateUser(username: string, pass: string): Promise<any> { async validateUser(
username: string,
pass: string,
binding: LinkBinding = {},
): Promise<any> {
await this.userLinkAccessService.assertMobileAllowed({
mobile: username,
linkToken: binding.linkToken,
linkContext: binding.linkContext,
});
const user = await this.userDbService.findOne({ username }); const user = await this.userDbService.findOne({ username });
if (!user) throw new NotFoundException("user not found"); if (!user) throwUserAuthError(UserAuthErrorCode.USER_NOT_FOUND);
const now = new Date().getTime(); const now = new Date().getTime();
if (user.otp == null) throw new NotAcceptableException("please get otp"); if (user.otp == null) throwUserAuthError(UserAuthErrorCode.OTP_REQUIRED);
if (user.otpExpire < now) { if (user.otpExpire < now) {
throw new NotAcceptableException("expire otp"); throwUserAuthError(UserAuthErrorCode.OTP_EXPIRED);
} }
if (await this.hashService.compare(pass, user.otp)) { if (await this.hashService.compare(pass, user.otp)) {
return user; return user;
@@ -65,7 +78,16 @@ export class UserAuthService {
}; };
} }
async sendOtpRequest(mobile: string): Promise<LoginDtoRs> { async sendOtpRequest(
mobile: string,
binding: LinkBinding = {},
): Promise<LoginDtoRs> {
await this.userLinkAccessService.assertMobileAllowed({
mobile,
linkToken: binding.linkToken,
linkContext: binding.linkContext,
});
const userExist = await this.userDbService.findOne({ const userExist = await this.userDbService.findOne({
mobile, mobile,
}); });
@@ -101,7 +123,9 @@ export class UserAuthService {
return new LoginDtoRs(newUser); return new LoginDtoRs(newUser);
} }
if (userExist) { if (userExist) {
if (userExist.otpExpire > new Date(new Date().getTime()).getTime()) throw new BadRequestException("Wait for expiry time to finish"); if (userExist.otpExpire > new Date(new Date().getTime()).getTime()) {
throwUserAuthError(UserAuthErrorCode.OTP_REQUEST_TOO_SOON);
}
await this.smsSender(otp, mobile); await this.smsSender(otp, mobile);
const updateTokens = await this.userDbService.findOneAndUpdate( const updateTokens = await this.userDbService.findOneAndUpdate(
{ {

View File

@@ -1,5 +1,6 @@
import { Module } from "@nestjs/common"; import { Module } from "@nestjs/common";
import { JwtModule, JwtService } from "@nestjs/jwt"; import { JwtModule, JwtService } from "@nestjs/jwt";
import { MongooseModule } from "@nestjs/mongoose";
import { PassportModule } from "@nestjs/passport"; import { PassportModule } from "@nestjs/passport";
import { LocalStrategy } from "src/auth/stratregys/local.strategy"; import { LocalStrategy } from "src/auth/stratregys/local.strategy";
import { LocalActorStrategy } from "src/auth/stratregys/local-actor.strategy"; import { LocalActorStrategy } from "src/auth/stratregys/local-actor.strategy";
@@ -7,7 +8,20 @@ import { ActorAuthController } from "src/auth/auth-controllers/actor/actor.auth.
import { UserAuthController } from "src/auth/auth-controllers/user/user.auth.controller"; import { UserAuthController } from "src/auth/auth-controllers/user/user.auth.controller";
import { ActorAuthService } from "src/auth/auth-services/actor.auth.service"; import { ActorAuthService } from "src/auth/auth-services/actor.auth.service";
import { UserAuthService } from "src/auth/auth-services/user.auth.service"; import { UserAuthService } from "src/auth/auth-services/user.auth.service";
import { UserLinkAccessService } from "src/auth/auth-services/user-link-access.service";
import {
ClaimRequestManagementModel,
ClaimRequestManagementSchema,
} from "src/claim-request-management/entites/schema/claim-request-management.schema";
import { ClientModule } from "src/client/client.module"; import { ClientModule } from "src/client/client.module";
import {
BlameRequest,
BlameRequestSchema,
} from "src/request-management/entities/schema/blame-cases.schema";
import {
RequestManagementModel,
RequestManagementSchema,
} from "src/request-management/entities/schema/request-management.schema";
import { UsersModule } from "src/users/users.module"; import { UsersModule } from "src/users/users.module";
import { HashModule } from "src/utils/hash/hash.module"; import { HashModule } from "src/utils/hash/hash.module";
// import { MailModule } from "src/utils/mail/mail.module"; // import { MailModule } from "src/utils/mail/mail.module";
@@ -21,6 +35,14 @@ import { SmsOrchestrationModule } from "src/sms-orchestration/sms-orchestration.
HashModule, HashModule,
PassportModule, PassportModule,
SmsOrchestrationModule, SmsOrchestrationModule,
MongooseModule.forFeature([
{ name: RequestManagementModel.name, schema: RequestManagementSchema },
{ name: BlameRequest.name, schema: BlameRequestSchema },
{
name: ClaimRequestManagementModel.name,
schema: ClaimRequestManagementSchema,
},
]),
JwtModule.register({ JwtModule.register({
signOptions: { expiresIn: "1h" }, signOptions: { expiresIn: "1h" },
global: true, global: true,
@@ -29,6 +51,7 @@ import { SmsOrchestrationModule } from "src/sms-orchestration/sms-orchestration.
], ],
providers: [ providers: [
UserAuthService, UserAuthService,
UserLinkAccessService,
ActorAuthService, ActorAuthService,
LocalStrategy, LocalStrategy,
LocalActorStrategy, LocalActorStrategy,

View File

@@ -1,4 +1,4 @@
import { ApiProperty } from "@nestjs/swagger"; import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { UserModel } from "src/users/entities/schema/user.schema"; import { UserModel } from "src/users/entities/schema/user.schema";
export class UserLoginDto { export class UserLoginDto {
@@ -8,6 +8,20 @@ export class UserLoginDto {
description: "User login dto", description: "User login dto",
}) })
mobile: string; mobile: string;
@ApiPropertyOptional({
example: "65f0c7f0c3f8a2a7c8b3d001",
type: "string",
description: "Raw token from linked SMS URL (?token=...).",
})
linkToken?: string;
@ApiPropertyOptional({
example: "FIRST",
type: "string",
description: "Optional route/context hint for linked SMS login.",
})
linkContext?: string;
} }
export class LoginDtoRs extends UserModel { export class LoginDtoRs extends UserModel {

View File

@@ -1,4 +1,4 @@
import { ApiProperty } from "@nestjs/swagger"; import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
export class UserVerifyOtp { export class UserVerifyOtp {
@ApiProperty({ @ApiProperty({
@@ -14,4 +14,18 @@ export class UserVerifyOtp {
description: "User login verify dto", description: "User login verify dto",
}) })
password: string; password: string;
@ApiPropertyOptional({
example: "65f0c7f0c3f8a2a7c8b3d001",
type: "string",
description: "Raw token from linked SMS URL (?token=...).",
})
linkToken?: string;
@ApiPropertyOptional({
example: "FIRST",
type: "string",
description: "Optional route/context hint for linked SMS login.",
})
linkContext?: string;
} }

View File

@@ -1,9 +1,9 @@
import { import { ExecutionContext, Injectable } from "@nestjs/common";
ExecutionContext,
Injectable,
NotAcceptableException,
} from "@nestjs/common";
import { AuthGuard } from "@nestjs/passport"; import { AuthGuard } from "@nestjs/passport";
import {
UserAuthErrorCode,
throwUserAuthError,
} from "src/auth/auth-services/user-auth-error";
import { UserAuthService } from "src/auth/auth-services/user.auth.service"; import { UserAuthService } from "src/auth/auth-services/user.auth.service";
@Injectable() @Injectable()
@@ -13,13 +13,14 @@ export class LocalUserAuthGuard extends AuthGuard("local") {
} }
async canActivate(context: ExecutionContext): Promise<boolean> { async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest(); const request = context.switchToHttp().getRequest();
const { username, password } = request.body; const { username, password, linkToken, linkContext } = request.body;
let isValidUser = await this.userAuthService.validateUser( const isValidUser = await this.userAuthService.validateUser(
username, username,
password, password,
{ linkToken, linkContext },
); );
if (!isValidUser) { if (!isValidUser) {
throw new NotAcceptableException("otp is wrong"); throwUserAuthError(UserAuthErrorCode.OTP_INVALID);
} }
request["user"] = isValidUser; request["user"] = isValidUser;
return true; return true;

View File

@@ -1,6 +1,10 @@
import { Injectable, UnauthorizedException } from "@nestjs/common"; import { Injectable } from "@nestjs/common";
import { PassportStrategy } from "@nestjs/passport"; import { PassportStrategy } from "@nestjs/passport";
import { Strategy } from "passport-local"; import { Strategy } from "passport-local";
import {
UserAuthErrorCode,
throwUserAuthError,
} from "src/auth/auth-services/user-auth-error";
import { UserAuthService } from "src/auth/auth-services/user.auth.service"; import { UserAuthService } from "src/auth/auth-services/user.auth.service";
@Injectable() @Injectable()
@@ -12,7 +16,7 @@ export class LocalStrategy extends PassportStrategy(Strategy) {
async validate(username: string, password: string): Promise<any> { async validate(username: string, password: string): Promise<any> {
const user = await this.userAuthService.validateUser(username, password); const user = await this.userAuthService.validateUser(username, password);
if (!user) { if (!user) {
throw new UnauthorizedException("user not found please register"); throwUserAuthError(UserAuthErrorCode.OTP_INVALID);
} }
return user; return user;
} }