Files
CRM_Montzar/apps/api/src/auth/auth.service.ts
T
2026-09-15 14:10:52 +03:30

629 lines
19 KiB
TypeScript

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<string, string> = {
'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<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 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<LoginResponse> {
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<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('کد تأیید نامعتبر یا منقضی است');
}
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<AuthUserDto> {
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<AuthUserDto> {
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<AuthUserDto> {
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<AuthUserDto> {
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<string | null> {
const rows = await this.prisma.$queryRaw<Array<{ avatarStorageKey: string | null }>>`
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<AuthUserDto> {
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<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 isEmailOtpConfigured(): boolean {
return this.config.get<string>('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<LoginResponse> {
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 } : {}),
};
}
}