import { BadRequestException, ConflictException, Injectable, Logger, NotFoundException, UnauthorizedException, } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { JwtService } from '@nestjs/jwt'; import * as bcrypt from 'bcryptjs'; import * as path from 'path'; 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 { LocalFileStorage } from '../storage/local-file.storage'; import { LoginDto, LoginOtpRequestDto, LoginOtpVerifyDto, RegisterOrganizationDto, RegisterVerifyOtpDto, UpdateProfileDto, } from './dto'; const OTP_LENGTH = 6; const OTP_TTL_SECONDS = 5 * 60; const AVATAR_MAX_BYTES = 2 * 1024 * 1024; const AVATAR_EXT: Record = { 'image/jpeg': 'jpg', 'image/jpg': 'jpg', 'image/png': 'png', 'image/webp': 'webp', 'image/gif': 'gif', }; 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, private readonly files: LocalFileStorage, ) {} /** * ثبت‌نام مرحله ۱: * - همیشه OTP موبایل * - OTP ایمیل فقط وقتی AUTH_EMAIL_OTP_ENABLED=true */ 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 returnOtp = this.shouldReturnOtp(); const emailOtpEnabled = this.isEmailOtpConfigured(); const mobileCode = generateOtp(); const emailCode = emailOtpEnabled ? generateOtp() : null; 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), }, }); if (emailOtpEnabled && emailCode) { 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), emailOtpRequired: emailOtpEnabled, ...(returnOtp ? { ...(emailCode ? { devEmailCode: emailCode } : {}), devMobileCode: mobileCode, } : {}), }; } /** ثبت‌نام مرحله ۲: تأیید OTP و ساخت سازمان */ 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 mobileOk = challenge.mobileCode === dto.mobileCode.trim(); if (!mobileOk) { throw new UnauthorizedException('کد تأیید موبایل نادرست است'); } const emailRequired = Boolean(challenge.emailCode); if (emailRequired) { const emailOk = challenge.emailCode === (dto.emailCode ?? '').trim(); if (!emailOk) { 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: emailRequired ? now : null, mobileVerifiedAt: now, }, }, }, include: { users: true }, }); await this.prisma.authChallenge.update({ where: { id: challenge.id }, data: { consumedAt: now, emailVerified: emailRequired, mobileVerified: true, }, }); return this.buildLoginResponse(organization.users[0]); } /** ورود با رمز: ایمیل یا موبایل + رمز → JWT (بدون OTP) */ async loginWithPassword(dto: LoginDto): Promise { 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('اطلاعات ورود نادرست است'); } return this.buildLoginResponse(user); } /** ورود اختیاری با موبایل + OTP (بدون رمز) — فقط کاربران ثبت‌شده */ async requestLoginOtp(dto: LoginOtpRequestDto) { const mobile = normalizeMobile(dto.mobile); if (!mobile) { throw new BadRequestException('شماره موبایل معتبر نیست'); } const user = await this.prisma.user.findFirst({ where: { mobile } }); if (!user) { throw new UnauthorizedException('حسابی با این موبایل یافت نشد'); } await this.throttleChallenges('login', user.email, mobile); const code = generateOtp(); const returnOtp = this.shouldReturnOtp(); const challenge = await this.prisma.authChallenge.create({ data: { kind: 'login', channel: 'mobile', email: user.email, mobile, payload: { userId: user.id } as Prisma.InputJsonValue, mobileCode: code, expiresAt: new Date(Date.now() + OTP_TTL_SECONDS * 1000), }, }); await this.dispatchMobileOtp(mobile, code, returnOtp); return { challengeId: challenge.id, channel: 'mobile' as const, expiresInSeconds: OTP_TTL_SECONDS, maskedDestination: maskMobile(mobile), ...(returnOtp ? { devCode: code } : {}), }; } async verifyLoginOtp(dto: LoginOtpVerifyDto): 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('کد تأیید نامعتبر یا منقضی است'); } if (!challenge.mobileCode || challenge.mobileCode !== dto.code.trim()) { throw new UnauthorizedException('کد تأیید نادرست است'); } const payload = (challenge.payload ?? {}) as { userId?: string }; if (!payload.userId) { throw new BadRequestException('چالش ورود ناقص است'); } const user = await this.prisma.user.findUnique({ where: { id: payload.userId } }); if (!user) { throw new UnauthorizedException(); } const now = new Date(); await this.prisma.authChallenge.update({ where: { id: challenge.id }, data: { consumedAt: now, mobileVerified: true }, }); if (!user.mobileVerifiedAt) { await this.prisma.user.update({ where: { id: user.id }, data: { mobileVerifiedAt: now }, }); } return this.buildLoginResponse(user); } async me(userId: string): Promise { const user = await this.prisma.user.findUnique({ where: { id: userId } }); if (!user) { throw new UnauthorizedException(); } return this.toAuthUserDto(user); } async updateMe(userId: string, dto: UpdateProfileDto): Promise { const fullName = dto.fullName.trim(); if (fullName.length < 2) { throw new BadRequestException('نام باید حداقل دو حرف باشد'); } const email = dto.email.trim().toLowerCase(); const mobile = normalizeMobile(dto.mobile); if (!mobile) { throw new BadRequestException('شماره موبایل معتبر نیست'); } const existing = await this.prisma.user.findUnique({ where: { id: userId } }); if (!existing) { throw new UnauthorizedException(); } if (email !== existing.email) { const taken = await this.prisma.user.findFirst({ where: { organizationId: existing.organizationId, email, id: { not: userId } }, }); if (taken) { throw new ConflictException('این ایمیل قبلاً ثبت شده است'); } } if (mobile !== existing.mobile) { const taken = await this.prisma.user.findFirst({ where: { mobile, id: { not: userId } }, }); if (taken) { throw new ConflictException('این شماره موبایل قبلاً ثبت شده است'); } } try { const user = await this.prisma.user.update({ where: { id: userId }, data: { fullName, email, mobile }, }); return this.toAuthUserDto(user); } catch (error) { if ( error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002' ) { throw new ConflictException('این ایمیل یا شماره موبایل قبلاً ثبت شده است'); } throw error; } } async saveAvatar( userId: string, organizationId: string, file: Express.Multer.File | undefined, ): Promise { if (!file?.buffer?.length) { throw new BadRequestException('فایل تصویر ارسال نشده'); } if (file.size > AVATAR_MAX_BYTES) { throw new BadRequestException('حجم تصویر باید کمتر از ۲ مگابایت باشد'); } const mime = (file.mimetype || '').toLowerCase(); const ext = AVATAR_EXT[mime]; if (!ext) { throw new BadRequestException('فقط تصویر JPG، PNG، WebP یا GIF مجاز است'); } const existing = await this.prisma.user.findUnique({ where: { id: userId } }); if (!existing) { throw new UnauthorizedException(); } const prevKey = await this.readAvatarKey(userId); const storageKey = path .join(organizationId, 'users', userId, `avatar.${ext}`) .replace(/\\/g, '/'); await this.files.save(storageKey, file.buffer); if (prevKey && prevKey !== storageKey) { await this.files.remove(prevKey); } await this.writeAvatarKey(userId, storageKey); return this.toAuthUserDto(existing); } async removeAvatar(userId: string): Promise { const existing = await this.prisma.user.findUnique({ where: { id: userId } }); if (!existing) { throw new UnauthorizedException(); } const prevKey = await this.readAvatarKey(userId); if (prevKey) await this.files.remove(prevKey); await this.writeAvatarKey(userId, null); return this.toAuthUserDto(existing); } async openAvatar(userId: string) { const user = await this.prisma.user.findUnique({ where: { id: userId } }); if (!user) { throw new UnauthorizedException(); } const storageKey = await this.readAvatarKey(userId); if (!storageKey) { throw new NotFoundException('عکس پروفایل تنظیم نشده'); } const ext = path.extname(storageKey).slice(1).toLowerCase(); const mime = Object.entries(AVATAR_EXT).find(([, value]) => value === ext)?.[0] ?? 'image/jpeg'; return { stream: this.files.openReadStream(storageKey), mimeType: mime, }; } private async readAvatarKey(userId: string): Promise { const rows = await this.prisma.$queryRaw>` SELECT "avatarStorageKey" FROM "User" WHERE "id" = ${userId} `; return rows[0]?.avatarStorageKey ?? null; } private async writeAvatarKey(userId: string, storageKey: string | null) { await this.prisma.$executeRaw` UPDATE "User" SET "avatarStorageKey" = ${storageKey} WHERE "id" = ${userId} `; } private async toAuthUserDto(user: { id: string; email: string; mobile?: string | null; fullName: string; role: AuthUserDto['role']; organizationId: string; }): Promise { return this.toAuthUser({ ...user, avatarStorageKey: await this.readAvatarKey(user.id), }); } 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 isEmailOtpConfigured(): boolean { return this.config.get('AUTH_EMAIL_OTP_ENABLED') === 'true'; } private async dispatchEmailOtp( email: string, code: string, returnOtp: boolean, ) { this.logger.log(`[auth-otp-email] ${email} => ${code}`); if (!this.isEmailOtpConfigured() && !returnOtp) { 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 async buildLoginResponse(user: { id: string; email: string; mobile?: string | null; fullName: string; role: AuthUserDto['role']; organizationId: string; }): Promise { const payload = { sub: user.id, organizationId: user.organizationId, role: user.role, }; return { accessToken: this.jwt.sign(payload), user: await this.toAuthUserDto(user), }; } private toAuthUser(user: { id: string; email: string; mobile?: string | null; fullName: string; role: AuthUserDto['role']; organizationId: string; avatarStorageKey?: string | null; }): AuthUserDto { return { id: user.id, email: user.email, fullName: user.fullName, role: user.role, organizationId: user.organizationId, hasAvatar: Boolean(user.avatarStorageKey), ...(user.mobile ? { mobile: user.mobile } : {}), }; } }