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 { 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()}` ); } /** نرمال‌سازی موبایل ایران به 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, ) {} /** مرحله ۱ ثبت‌نام: اعتبارسنجی + ارسال 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 { 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 now = new Date(); const organization = await this.prisma.organization.create({ data: { name: payload.organizationName, slug, businessField: payload.businessField, users: { create: { email, mobile, fullName: payload.fullName, passwordHash: challenge.passwordHash, role: 'owner', emailVerifiedAt: now, mobileVerifiedAt: now, }, }, }, include: { users: true }, }); await this.prisma.authChallenge.update({ where: { id: challenge.id }, data: { consumedAt: now, emailVerified: true, mobileVerified: true, }, }); return this.buildLoginResponse(organization.users[0]); } /** مرحله ۱ ورود: ایمیل/موبایل + رمز → ارسال 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 ? { email } : { mobile: mobile! }, }); if (!user) { throw new UnauthorizedException('اطلاعات ورود نادرست است'); } const ok = await bcrypt.compare(dto.password, user.passwordHash); if (!ok) { 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 { 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 { const user = await this.prisma.user.findUnique({ where: { id: userId } }); if (!user) { throw new UnauthorizedException(); } 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('AUTH_RETURN_OTP') === 'true' || this.config.get('PORTAL_SHOW_OTP') === 'true' || (this.config.get('NODE_ENV') !== 'production' && this.config.get('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('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; }): LoginResponse { const payload = { sub: user.id, organizationId: user.organizationId, role: user.role, }; return { accessToken: this.jwt.sign(payload), user: this.toAuthUser(user), }; } private toAuthUser(user: { id: string; email: string; mobile?: string | null; fullName: string; role: AuthUserDto['role']; organizationId: string; }): AuthUserDto { return { id: user.id, email: user.email, fullName: user.fullName, role: user.role, organizationId: user.organizationId, ...(user.mobile ? { mobile: user.mobile } : {}), }; } }