470 lines
15 KiB
TypeScript
470 lines
15 KiB
TypeScript
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<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 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<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) {
|
|
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<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;
|
|
}): 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 } : {}),
|
|
};
|
|
}
|
|
}
|