@@ -1,83 +1,357 @@
import {
BadRequestException ,
ConflictException ,
Injectable ,
Logger ,
UnauthorizedException ,
} from '@nestjs/common' ;
import { ConfigService } from '@nestjs/config' ;
import { JwtService } from '@nestjs/jwt' ;
import * as bcrypt from 'bcryptjs' ;
import type { AuthUserDto , LoginResponse } from '@mento/shared' ;
import { Prisma } from '@prisma/client' ;
import { PrismaService } from '../prisma/prisma.service' ;
import { LoginDto , RegisterOrganizationDto } from './dto ' ;
import { SmsService } from '../common/sms.service ' ;
import {
LoginDto ,
LoginVerifyOtpDto ,
RegisterOrganizationDto ,
RegisterVerifyOtpDto ,
} from './dto' ;
const OTP_LENGTH = 6 ;
const OTP_TTL_SECONDS = 5 * 60 ;
function slugify ( input : string ) : string {
return input
. trim ( )
. toLowerCase ( )
. replace ( /[^\w\u0600-\u06FF]+/g , '-' )
. replace ( /^-+|-+$ /g , '' )
. slice ( 0 , 48 ) || ` org- ${ Date . now ( ) } ` ;
return (
input
. trim ( )
. toLowerCase ( )
. replace ( /[^\w\u0600-\u06FF]+ /g , '- ' )
. replace ( /^-+|-+$/g , '' )
. slice ( 0 , 48 ) || ` org- ${ Date . now ( ) } `
) ;
}
/** نرمالسازی موبایل ایران به 09xxxxxxxxx */
export function normalizeMobile ( raw : string ) : string | null {
let s = raw . replace ( /[\s\-()]/g , '' ) ;
if ( s . startsWith ( '+98' ) ) s = ` 0 ${ s . slice ( 3 ) } ` ;
if ( s . startsWith ( '98' ) && s . length === 12 ) s = ` 0 ${ s . slice ( 2 ) } ` ;
if ( /^9\d{9}$/ . test ( s ) ) s = ` 0 ${ s } ` ;
if ( /^09\d{9}$/ . test ( s ) ) return s ;
return null ;
}
function looksLikeEmail ( value : string ) : boolean {
return value . includes ( '@' ) ;
}
function maskEmail ( email : string ) : string {
const [ local , domain ] = email . split ( '@' ) ;
if ( ! domain ) return '***' ;
const head = local . slice ( 0 , Math . min ( 2 , local . length ) ) ;
return ` ${ head } ***@ ${ domain } ` ;
}
function maskMobile ( mobile : string ) : string {
if ( mobile . length < 4 ) return '****' ;
return ` ${ mobile . slice ( 0 , 4 ) } *** ${ mobile . slice ( - 2 ) } ` ;
}
function generateOtp ( ) : string {
return String (
Math . floor ( 10 * * ( OTP_LENGTH - 1 ) + Math . random ( ) * 9 * 10 * * ( OTP_LENGTH - 1 ) ) ,
) ;
}
type RegisterPayload = {
organizationName : string ;
fullName : string ;
businessField : string ;
referralCode? : string ;
} ;
@Injectable ( )
export class AuthService {
private readonly logger = new Logger ( AuthService . name ) ;
constructor (
private readonly prisma : PrismaService ,
private readonly jwt : JwtService ,
private readonly config : ConfigService ,
private readonly sms : SmsService ,
) { }
async registerOrganization ( dto : RegisterOrganizationDto ) : Promise < LoginResponse > {
const baseSlug = slugify ( dto . organizationName ) ;
/** مرحله ۱ ثبتنام: اعتبارسنجی + ارسال OTP ایمیل و موبایل */
async startRegister ( dto : RegisterOrganizationDto ) {
if ( dto . password !== dto . passwordConfirm ) {
throw new BadRequestException ( 'تکرار رمز عبور مطابقت ندارد' ) ;
}
if ( dto . acceptedTerms === false ) {
throw new BadRequestException ( 'پذیرش شرایط و قوانین الزامی است' ) ;
}
const email = dto . email . trim ( ) . toLowerCase ( ) ;
const mobile = normalizeMobile ( dto . mobile ) ;
if ( ! mobile ) {
throw new BadRequestException ( 'شماره موبایل معتبر نیست' ) ;
}
const existingEmail = await this . prisma . user . findFirst ( { where : { email } } ) ;
if ( existingEmail ) {
throw new ConflictException ( 'این ایمیل قبلاً ثبت شده است' ) ;
}
const existingMobile = await this . prisma . user . findFirst ( { where : { mobile } } ) ;
if ( existingMobile ) {
throw new ConflictException ( 'این شماره موبایل قبلاً ثبت شده است' ) ;
}
await this . throttleChallenges ( 'register' , email , mobile ) ;
const emailCode = generateOtp ( ) ;
const mobileCode = generateOtp ( ) ;
const passwordHash = await bcrypt . hash ( dto . password , 10 ) ;
const payload : RegisterPayload = {
organizationName : dto.organizationName.trim ( ) ,
fullName : dto.fullName.trim ( ) ,
businessField : dto.businessField ,
. . . ( dto . referralCode ? . trim ( )
? { referralCode : dto.referralCode.trim ( ) }
: { } ) ,
} ;
const challenge = await this . prisma . authChallenge . create ( {
data : {
kind : 'register' ,
email ,
mobile ,
passwordHash ,
payload : payload as Prisma . InputJsonValue ,
emailCode ,
mobileCode ,
expiresAt : new Date ( Date . now ( ) + OTP_TTL_SECONDS * 1000 ) ,
} ,
} ) ;
const returnOtp = this . shouldReturnOtp ( ) ;
await this . dispatchEmailOtp ( email , emailCode , returnOtp ) ;
await this . dispatchMobileOtp ( mobile , mobileCode , returnOtp ) ;
return {
challengeId : challenge.id ,
expiresInSeconds : OTP_TTL_SECONDS ,
maskedEmail : maskEmail ( email ) ,
maskedMobile : maskMobile ( mobile ) ,
. . . ( returnOtp
? { devEmailCode : emailCode , devMobileCode : mobileCode }
: { } ) ,
} ;
}
/** مرحله ۲ ثبتنام: تأیید هر دو کد و ساخت سازمان */
async verifyRegister ( dto : RegisterVerifyOtpDto ) : Promise < LoginResponse > {
const challenge = await this . prisma . authChallenge . findUnique ( {
where : { id : dto.challengeId } ,
} ) ;
if (
! challenge ||
challenge . kind !== 'register' ||
challenge . consumedAt ||
challenge . expiresAt <= new Date ( )
) {
throw new UnauthorizedException ( 'کد تأیید نامعتبر یا منقضی است' ) ;
}
const emailOk = challenge . emailCode === dto . emailCode . trim ( ) ;
const mobileOk = challenge . mobileCode === dto . mobileCode . trim ( ) ;
if ( ! emailOk || ! mobileOk ) {
throw new UnauthorizedException ( 'کد تأیید ایمیل یا موبایل نادرست است' ) ;
}
const email = ( challenge . email ? ? '' ) . toLowerCase ( ) ;
const mobile = challenge . mobile ? ? '' ;
if ( ! email || ! mobile || ! challenge . passwordHash ) {
throw new BadRequestException ( 'چالش ثبتنام ناقص است' ) ;
}
const payload = ( challenge . payload ? ? { } ) as RegisterPayload ;
if ( ! payload . organizationName || ! payload . fullName || ! payload . businessField ) {
throw new BadRequestException ( 'اطلاعات ثبتنام ناقص است' ) ;
}
const existingEmail = await this . prisma . user . findFirst ( { where : { email } } ) ;
if ( existingEmail ) {
throw new ConflictException ( 'این ایمیل قبلاً ثبت شده است' ) ;
}
const existingMobile = await this . prisma . user . findFirst ( { where : { mobile } } ) ;
if ( existingMobile ) {
throw new ConflictException ( 'این شماره موبایل قبلاً ثبت شده است' ) ;
}
const baseSlug = slugify ( payload . organizationName ) ;
let slug = baseSlug ;
let i = 1 ;
while ( await this . prisma . organization . findUnique ( { where : { slug } } ) ) {
slug = ` ${ baseSlug } - ${ i ++ } ` ;
}
const existing = await this . prisma . user . findFirst ( {
where : { email : dto.email.toLowerCase ( ) } ,
} ) ;
if ( existing ) {
throw new ConflictException ( 'این ایمیل قبلاً ثبت شده است' ) ;
}
const passwordHash = await bcrypt . hash ( dto . password , 10 ) ;
const now = new Date ( ) ;
const organization = await this . prisma . organization . create ( {
data : {
name : dto.organizationName ,
name : payload.organizationName ,
slug ,
businessField : dto.businessField ,
businessField : payload.businessField ,
users : {
create : {
email : dto.email.toLowerCase ( ) ,
fullName : dto.fullName ,
passwordHash ,
email ,
mobile ,
fullName : payload.fullName ,
passwordHash : challenge.passwordHash ,
role : 'owner' ,
emailVerifiedAt : now ,
mobileVerifiedAt : now ,
} ,
} ,
} ,
include : { users : true } ,
} ) ;
const user = organization . users [ 0 ] ;
return this . buildLoginResponse ( user ) ;
await this . prisma . authChallenge . update ( {
where : { id : challenge.id } ,
data : {
consumedAt : now ,
emailVerified : true ,
mobileVerified : true ,
} ,
} ) ;
return this . buildLoginResponse ( organization . users [ 0 ] ) ;
}
async login ( dto : LoginDto ) : Promise < LoginResponse > {
/** مرحله ۱ ورود: ایمیل/موبایل + رمز → ارسال OTP روی همان کانال */
async startLogin ( dto : LoginDto ) {
const identifier = ( dto . identifier ? ? dto . email ? ? '' ) . trim ( ) ;
const isEmail = looksLikeEmail ( identifier ) ;
const email = isEmail ? identifier . toLowerCase ( ) : null ;
const mobile = isEmail ? null : normalizeMobile ( identifier ) ;
if ( ! identifier || ( ! email && ! mobile ) ) {
throw new BadRequestException ( 'ایمیل یا شماره موبایل معتبر وارد کنید' ) ;
}
const user = await this . prisma . user . findFirst ( {
where : { email : dto.email.toLowerCase ( ) } ,
where : email ? { email } : { mobile : mobile ! } ,
} ) ;
if ( ! user ) {
throw new UnauthorizedException ( 'ا یمیل یا رمز عبور نادرست است' ) ;
throw new UnauthorizedException ( 'ا طلاعات ورود نادرست است' ) ;
}
const ok = await bcrypt . compare ( dto . password , user . passwordHash ) ;
if ( ! ok ) {
throw new UnauthorizedException ( 'ا یمیل یا رمز عبور نادرست است' ) ;
throw new UnauthorizedException ( 'ا طلاعات ورود نادرست است' ) ;
}
const channel = email ? 'email' : 'mobile' ;
if ( channel === 'mobile' && ! user . mobile ) {
throw new BadRequestException ( 'موبایل برای این حساب ثبت نشده است' ) ;
}
if ( channel === 'email' && ! user . email ) {
throw new BadRequestException ( 'ایمیل برای این حساب ثبت نشده است' ) ;
}
await this . throttleChallenges ( 'login' , user . email , user . mobile ) ;
const code = generateOtp ( ) ;
const challenge = await this . prisma . authChallenge . create ( {
data : {
kind : 'login' ,
channel ,
email : user.email ,
mobile : user.mobile ,
passwordHash : null ,
payload : { userId : user.id } as Prisma . InputJsonValue ,
emailCode : channel === 'email' ? code : null ,
mobileCode : channel === 'mobile' ? code : null ,
expiresAt : new Date ( Date . now ( ) + OTP_TTL_SECONDS * 1000 ) ,
} ,
} ) ;
const returnOtp = this . shouldReturnOtp ( ) ;
if ( channel === 'email' ) {
await this . dispatchEmailOtp ( user . email , code , returnOtp ) ;
} else {
await this . dispatchMobileOtp ( user . mobile ! , code , returnOtp ) ;
}
return {
challengeId : challenge.id ,
channel : channel as 'email' | 'mobile' ,
expiresInSeconds : OTP_TTL_SECONDS ,
maskedDestination :
channel === 'email' ? maskEmail ( user . email ) : maskMobile ( user . mobile ! ) ,
. . . ( returnOtp ? { devCode : code } : { } ) ,
} ;
}
/** مرحله ۲ ورود: تأیید OTP */
async verifyLogin ( dto : LoginVerifyOtpDto ) : Promise < LoginResponse > {
const challenge = await this . prisma . authChallenge . findUnique ( {
where : { id : dto.challengeId } ,
} ) ;
if (
! challenge ||
challenge . kind !== 'login' ||
challenge . consumedAt ||
challenge . expiresAt <= new Date ( )
) {
throw new UnauthorizedException ( 'کد تأیید نامعتبر یا منقضی است' ) ;
}
const code = dto . code . trim ( ) ;
const expected =
challenge . channel === 'email' ? challenge.emailCode : challenge.mobileCode ;
if ( ! expected || expected !== code ) {
throw new UnauthorizedException ( 'کد تأیید نادرست است' ) ;
}
const payload = ( challenge . payload ? ? { } ) as { userId? : string } ;
const userId = payload . userId ;
if ( ! userId ) {
throw new BadRequestException ( 'چالش ورود ناقص است' ) ;
}
const user = await this . prisma . user . findUnique ( { where : { id : userId } } ) ;
if ( ! user ) {
throw new UnauthorizedException ( ) ;
}
const now = new Date ( ) ;
await this . prisma . authChallenge . update ( {
where : { id : challenge.id } ,
data : {
consumedAt : now ,
emailVerified : challenge.channel === 'email' ,
mobileVerified : challenge.channel === 'mobile' ,
} ,
} ) ;
await this . prisma . user . update ( {
where : { id : user.id } ,
data :
challenge.channel === 'email'
? { emailVerifiedAt : user.emailVerifiedAt ? ? now }
: { mobileVerifiedAt : user.mobileVerifiedAt ? ? now } ,
} ) ;
return this . buildLoginResponse ( user ) ;
}
/** سازگاری موقت: اگر کلاینت قدیمی email بفرستد */
async loginLegacy ( body : LoginDto ) {
return this . startLogin ( body ) ;
}
async me ( userId : string ) : Promise < AuthUserDto > {
const user = await this . prisma . user . findUnique ( { where : { id : userId } } ) ;
if ( ! user ) {
@@ -86,9 +360,80 @@ export class AuthService {
return this . toAuthUser ( user ) ;
}
private async throttleChallenges (
kind : string ,
email : string | null | undefined ,
mobile : string | null | undefined ,
) {
const since = new Date ( Date . now ( ) - 60 * 60 * 1000 ) ;
const count = await this . prisma . authChallenge . count ( {
where : {
kind ,
createdAt : { gte : since } ,
OR : [
. . . ( email ? [ { email } ] : [ ] ) ,
. . . ( mobile ? [ { mobile } ] : [ ] ) ,
] ,
} ,
} ) ;
if ( count >= 12 ) {
throw new BadRequestException (
'درخواست زیاد است؛ کمی بعد دوباره تلاش کنید' ,
) ;
}
}
private shouldReturnOtp ( ) : boolean {
return (
this . config . get < string > ( 'AUTH_RETURN_OTP' ) === 'true' ||
this . config . get < string > ( 'PORTAL_SHOW_OTP' ) === 'true' ||
( this . config . get < string > ( 'NODE_ENV' ) !== 'production' &&
this . config . get < string > ( 'AUTH_RETURN_OTP' ) !== 'false' )
) ;
}
private async dispatchEmailOtp (
email : string ,
code : string ,
returnOtp : boolean ,
) {
// فعلاً سرویس ایمیل پیکربندی نشده؛ در محیط غیرپروداکشن کد لاگ میشود
this . logger . log ( ` [auth-otp-email] ${ email } => ${ code } ` ) ;
if ( ! returnOtp && this . config . get < string > ( 'NODE_ENV' ) === 'production' ) {
// بدون ایمیل واقعی در پروداکشن، ورود/ثبتنام با ایمیل ممکن نیست مگر AUTH_RETURN_OTP
throw new BadRequestException (
'ارسال ایمیل پیکربندی نشده است؛ با پشتیبانی تماس بگیرید' ,
) ;
}
}
private async dispatchMobileOtp (
mobile : string ,
code : string ,
returnOtp : boolean ,
) {
if ( this . sms . isConfigured ( ) ) {
const sent = await this . sms . sendOtpSms ( mobile , code ) ;
if ( ! sent && ! returnOtp ) {
throw new BadRequestException (
'ارسال پیامک ناموفق بود؛ کمی بعد دوباره تلاش کنید' ,
) ;
}
if ( ! sent ) {
this . logger . warn ( ` [auth-otp-sms-fallback] ${ mobile } => ${ code } ` ) ;
}
return ;
}
if ( ! returnOtp ) {
throw new BadRequestException ( 'سامانه پیامک پیکربندی نشده است' ) ;
}
this . logger . log ( ` [auth-otp-sms] ${ mobile } => ${ code } ` ) ;
}
private buildLoginResponse ( user : {
id : string ;
email : string ;
mobile? : string | null ;
fullName : string ;
role : AuthUserDto [ 'role' ] ;
organizationId : string ;
@@ -107,6 +452,7 @@ export class AuthService {
private toAuthUser ( user : {
id : string ;
email : string ;
mobile? : string | null ;
fullName : string ;
role : AuthUserDto [ 'role' ] ;
organizationId : string ;
@@ -117,6 +463,7 @@ export class AuthService {
fullName : user.fullName ,
role : user.role ,
organizationId : user.organizationId ,
. . . ( user . mobile ? { mobile : user.mobile } : { } ) ,
} ;
}
}