From 5b6409fc2e1e6021a495128c25eab9ff8dbe1452 Mon Sep 17 00:00:00 2001 From: SepehrYahyaee <7heycallmegray@gmail.com> Date: Wed, 13 May 2026 09:23:45 +0330 Subject: [PATCH] Added linkToken and linkContext to OTP --- .../user/user.auth.controller.ts | 5 +- src/auth/auth-services/user-auth-error.ts | 46 +++++ .../auth-services/user-link-access.service.ts | 187 ++++++++++++++++++ src/auth/auth-services/user.auth.service.ts | 54 +++-- src/auth/auth.module.ts | 23 +++ src/auth/dto/user/login.dto.ts | 16 +- src/auth/dto/user/verify.dto.ts | 16 +- src/auth/guards/user-local.guard.ts | 17 +- src/auth/stratregys/local.strategy.ts | 8 +- 9 files changed, 344 insertions(+), 28 deletions(-) create mode 100644 src/auth/auth-services/user-auth-error.ts create mode 100644 src/auth/auth-services/user-link-access.service.ts diff --git a/src/auth/auth-controllers/user/user.auth.controller.ts b/src/auth/auth-controllers/user/user.auth.controller.ts index 25f5694..732c483 100644 --- a/src/auth/auth-controllers/user/user.auth.controller.ts +++ b/src/auth/auth-controllers/user/user.auth.controller.ts @@ -26,7 +26,10 @@ export class UserAuthController { }) @ApiAcceptedResponse() 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) { throw new HttpException(res, HttpStatus.ACCEPTED); } diff --git a/src/auth/auth-services/user-auth-error.ts b/src/auth/auth-services/user-auth-error.ts new file mode 100644 index 0000000..e663ea4 --- /dev/null +++ b/src/auth/auth-services/user-auth-error.ts @@ -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.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)); +} diff --git a/src/auth/auth-services/user-link-access.service.ts b/src/auth/auth-services/user-link-access.service.ts new file mode 100644 index 0000000..731441e --- /dev/null +++ b/src/auth/auth-services/user-link-access.service.ts @@ -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, + @InjectModel(BlameRequest.name) + private readonly blameRequestModel: Model, + @InjectModel(ClaimRequestManagementModel.name) + private readonly claimRequestManagementModel: Model, + private readonly userDbService: UserDbService, + ) {} + + async assertMobileAllowed(params: { + mobile: string; + linkToken?: string; + linkContext?: string; + }): Promise { + 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 { + if (!Types.ObjectId.isValid(linkToken)) return []; + + const id = new Types.ObjectId(linkToken); + const context = this.normalizeContext(linkContext); + const allowedMobiles = new Set(); + + 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, + 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, + 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, + 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, 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); + } +} diff --git a/src/auth/auth-services/user.auth.service.ts b/src/auth/auth-services/user.auth.service.ts index 35246d1..035de71 100644 --- a/src/auth/auth-services/user.auth.service.ts +++ b/src/auth/auth-services/user.auth.service.ts @@ -1,20 +1,22 @@ -import { - BadRequestException, - HttpException, - HttpStatus, - Injectable, - Logger, - NotAcceptableException, - NotFoundException, -} from "@nestjs/common"; +import { HttpException, HttpStatus, Injectable, Logger } from "@nestjs/common"; import { JwtService } from "@nestjs/jwt"; 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 { OtpGeneratorService } from "src/sms-orchestration/otp-generator.service"; import { UserDbService } from "src/users/entities/db-service/user.db.service"; import { SmsOrchestrationService } from "src/sms-orchestration/sms-orchestration.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 @Injectable() export class UserAuthService { @@ -26,16 +28,27 @@ export class UserAuthService { private readonly hashService: HashService, private readonly otpCreator: OtpGeneratorService, private readonly smsOrchestrationService: SmsOrchestrationService, + private readonly userLinkAccessService: UserLinkAccessService, ) {} - async validateUser(username: string, pass: string): Promise { + async validateUser( + username: string, + pass: string, + binding: LinkBinding = {}, + ): Promise { + await this.userLinkAccessService.assertMobileAllowed({ + mobile: username, + linkToken: binding.linkToken, + linkContext: binding.linkContext, + }); + 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(); - if (user.otp == null) throw new NotAcceptableException("please get otp"); + if (user.otp == null) throwUserAuthError(UserAuthErrorCode.OTP_REQUIRED); if (user.otpExpire < now) { - throw new NotAcceptableException("expire otp"); + throwUserAuthError(UserAuthErrorCode.OTP_EXPIRED); } if (await this.hashService.compare(pass, user.otp)) { return user; @@ -65,7 +78,16 @@ export class UserAuthService { }; } - async sendOtpRequest(mobile: string): Promise { + async sendOtpRequest( + mobile: string, + binding: LinkBinding = {}, + ): Promise { + await this.userLinkAccessService.assertMobileAllowed({ + mobile, + linkToken: binding.linkToken, + linkContext: binding.linkContext, + }); + const userExist = await this.userDbService.findOne({ mobile, }); @@ -101,7 +123,9 @@ export class UserAuthService { return new LoginDtoRs(newUser); } 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); const updateTokens = await this.userDbService.findOneAndUpdate( { diff --git a/src/auth/auth.module.ts b/src/auth/auth.module.ts index 5eee53e..5bd17a0 100644 --- a/src/auth/auth.module.ts +++ b/src/auth/auth.module.ts @@ -1,5 +1,6 @@ import { Module } from "@nestjs/common"; import { JwtModule, JwtService } from "@nestjs/jwt"; +import { MongooseModule } from "@nestjs/mongoose"; import { PassportModule } from "@nestjs/passport"; import { LocalStrategy } from "src/auth/stratregys/local.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 { ActorAuthService } from "src/auth/auth-services/actor.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 { + 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 { HashModule } from "src/utils/hash/hash.module"; // import { MailModule } from "src/utils/mail/mail.module"; @@ -21,6 +35,14 @@ import { SmsOrchestrationModule } from "src/sms-orchestration/sms-orchestration. HashModule, PassportModule, SmsOrchestrationModule, + MongooseModule.forFeature([ + { name: RequestManagementModel.name, schema: RequestManagementSchema }, + { name: BlameRequest.name, schema: BlameRequestSchema }, + { + name: ClaimRequestManagementModel.name, + schema: ClaimRequestManagementSchema, + }, + ]), JwtModule.register({ signOptions: { expiresIn: "1h" }, global: true, @@ -29,6 +51,7 @@ import { SmsOrchestrationModule } from "src/sms-orchestration/sms-orchestration. ], providers: [ UserAuthService, + UserLinkAccessService, ActorAuthService, LocalStrategy, LocalActorStrategy, diff --git a/src/auth/dto/user/login.dto.ts b/src/auth/dto/user/login.dto.ts index 6a8ffc4..e8a9ca2 100644 --- a/src/auth/dto/user/login.dto.ts +++ b/src/auth/dto/user/login.dto.ts @@ -1,4 +1,4 @@ -import { ApiProperty } from "@nestjs/swagger"; +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; import { UserModel } from "src/users/entities/schema/user.schema"; export class UserLoginDto { @@ -8,6 +8,20 @@ export class UserLoginDto { description: "User login dto", }) 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 { diff --git a/src/auth/dto/user/verify.dto.ts b/src/auth/dto/user/verify.dto.ts index 24428c4..abf46f3 100644 --- a/src/auth/dto/user/verify.dto.ts +++ b/src/auth/dto/user/verify.dto.ts @@ -1,4 +1,4 @@ -import { ApiProperty } from "@nestjs/swagger"; +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; export class UserVerifyOtp { @ApiProperty({ @@ -14,4 +14,18 @@ export class UserVerifyOtp { description: "User login verify dto", }) 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; } diff --git a/src/auth/guards/user-local.guard.ts b/src/auth/guards/user-local.guard.ts index 7d7e912..c4c5108 100644 --- a/src/auth/guards/user-local.guard.ts +++ b/src/auth/guards/user-local.guard.ts @@ -1,9 +1,9 @@ -import { - ExecutionContext, - Injectable, - NotAcceptableException, -} from "@nestjs/common"; +import { ExecutionContext, Injectable } from "@nestjs/common"; 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"; @Injectable() @@ -13,13 +13,14 @@ export class LocalUserAuthGuard extends AuthGuard("local") { } async canActivate(context: ExecutionContext): Promise { const request = context.switchToHttp().getRequest(); - const { username, password } = request.body; - let isValidUser = await this.userAuthService.validateUser( + const { username, password, linkToken, linkContext } = request.body; + const isValidUser = await this.userAuthService.validateUser( username, password, + { linkToken, linkContext }, ); if (!isValidUser) { - throw new NotAcceptableException("otp is wrong"); + throwUserAuthError(UserAuthErrorCode.OTP_INVALID); } request["user"] = isValidUser; return true; diff --git a/src/auth/stratregys/local.strategy.ts b/src/auth/stratregys/local.strategy.ts index 2d6a8bf..7f8ec30 100644 --- a/src/auth/stratregys/local.strategy.ts +++ b/src/auth/stratregys/local.strategy.ts @@ -1,6 +1,10 @@ -import { Injectable, UnauthorizedException } from "@nestjs/common"; +import { Injectable } from "@nestjs/common"; import { PassportStrategy } from "@nestjs/passport"; 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"; @Injectable() @@ -12,7 +16,7 @@ export class LocalStrategy extends PassportStrategy(Strategy) { async validate(username: string, password: string): Promise { const user = await this.userAuthService.validateUser(username, password); if (!user) { - throw new UnauthorizedException("user not found please register"); + throwUserAuthError(UserAuthErrorCode.OTP_INVALID); } return user; }