Prefer SMS for auth OTP when email mailer is not configured.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-05 01:22:55 +03:30
co-authored by Cursor
parent c620d1d6d4
commit 78511a7d53
4 changed files with 83 additions and 29 deletions
+52 -17
View File
@@ -109,8 +109,11 @@ export class AuthService {
await this.throttleChallenges('register', email, mobile); await this.throttleChallenges('register', email, mobile);
const emailCode = generateOtp(); const returnOtp = this.shouldReturnOtp();
const emailOtpEnabled = this.isEmailOtpConfigured();
const mobileCode = generateOtp(); const mobileCode = generateOtp();
// تا وقتی سرویس ایمیل نداریم، همان کد پیامک برای تأیید ایمیل هم پذیرفته می‌شود
const emailCode = emailOtpEnabled ? generateOtp() : mobileCode;
const passwordHash = await bcrypt.hash(dto.password, 10); const passwordHash = await bcrypt.hash(dto.password, 10);
const payload: RegisterPayload = { const payload: RegisterPayload = {
organizationName: dto.organizationName.trim(), organizationName: dto.organizationName.trim(),
@@ -134,8 +137,13 @@ export class AuthService {
}, },
}); });
const returnOtp = this.shouldReturnOtp(); if (emailOtpEnabled) {
await this.dispatchEmailOtp(email, emailCode, returnOtp); await this.dispatchEmailOtp(email, emailCode, returnOtp);
} else {
this.logger.warn(
`[auth-otp-email-skipped] ${email} — mailer disabled; SMS code covers both`,
);
}
await this.dispatchMobileOtp(mobile, mobileCode, returnOtp); await this.dispatchMobileOtp(mobile, mobileCode, returnOtp);
return { return {
@@ -143,8 +151,12 @@ export class AuthService {
expiresInSeconds: OTP_TTL_SECONDS, expiresInSeconds: OTP_TTL_SECONDS,
maskedEmail: maskEmail(email), maskedEmail: maskEmail(email),
maskedMobile: maskMobile(mobile), maskedMobile: maskMobile(mobile),
emailOtpRequired: emailOtpEnabled,
...(returnOtp ...(returnOtp
? { devEmailCode: emailCode, devMobileCode: mobileCode } ? {
devEmailCode: emailCode,
devMobileCode: mobileCode,
}
: {}), : {}),
}; };
} }
@@ -229,7 +241,7 @@ export class AuthService {
return this.buildLoginResponse(organization.users[0]); return this.buildLoginResponse(organization.users[0]);
} }
/** مرحله ۱ ورود: ایمیل/موبایل + رمز → ارسال OTP روی همان کانال */ /** مرحله ۱ ورود: ایمیل/موبایل + رمز → OTP ترجیحاً با پیامک */
async startLogin(dto: LoginDto) { async startLogin(dto: LoginDto) {
const identifier = (dto.identifier ?? dto.email ?? '').trim(); const identifier = (dto.identifier ?? dto.email ?? '').trim();
const isEmail = looksLikeEmail(identifier); const isEmail = looksLikeEmail(identifier);
@@ -252,13 +264,8 @@ export class AuthService {
throw new UnauthorizedException('اطلاعات ورود نادرست است'); throw new UnauthorizedException('اطلاعات ورود نادرست است');
} }
const channel = email ? 'email' : 'mobile'; const returnOtp = this.shouldReturnOtp();
if (channel === 'mobile' && !user.mobile) { const channel = this.resolveLoginOtpChannel(user, returnOtp);
throw new BadRequestException('موبایل برای این حساب ثبت نشده است');
}
if (channel === 'email' && !user.email) {
throw new BadRequestException('ایمیل برای این حساب ثبت نشده است');
}
await this.throttleChallenges('login', user.email, user.mobile); await this.throttleChallenges('login', user.email, user.mobile);
@@ -277,7 +284,6 @@ export class AuthService {
}, },
}); });
const returnOtp = this.shouldReturnOtp();
if (channel === 'email') { if (channel === 'email') {
await this.dispatchEmailOtp(user.email, code, returnOtp); await this.dispatchEmailOtp(user.email, code, returnOtp);
} else { } else {
@@ -286,7 +292,7 @@ export class AuthService {
return { return {
challengeId: challenge.id, challengeId: challenge.id,
channel: channel as 'email' | 'mobile', channel,
expiresInSeconds: OTP_TTL_SECONDS, expiresInSeconds: OTP_TTL_SECONDS,
maskedDestination: maskedDestination:
channel === 'email' ? maskEmail(user.email) : maskMobile(user.mobile!), channel === 'email' ? maskEmail(user.email) : maskMobile(user.mobile!),
@@ -392,15 +398,44 @@ export class AuthService {
); );
} }
/** تا وقتی SMTP/سرویس ایمیل واقعی نداریم خاموش است */
private isEmailOtpConfigured(): boolean {
return this.config.get<string>('AUTH_EMAIL_OTP_ENABLED') === 'true';
}
private resolveLoginOtpChannel(
user: { email: string; mobile: string | null },
returnOtp: boolean,
): 'email' | 'mobile' {
// ترجیح: پیامک (سرویس آماده است)
if (user.mobile) {
if (this.sms.isConfigured() || returnOtp) {
return 'mobile';
}
}
if (this.isEmailOtpConfigured() || returnOtp) {
return 'email';
}
if (!user.mobile) {
throw new BadRequestException(
'برای ورود باید موبایل روی حساب ثبت باشد (ارسال ایمیل فعال نیست)',
);
}
throw new BadRequestException(
'سامانه پیامک پیکربندی نشده است؛ با پشتیبانی تماس بگیرید',
);
}
private async dispatchEmailOtp( private async dispatchEmailOtp(
email: string, email: string,
code: string, code: string,
returnOtp: boolean, returnOtp: boolean,
) { ) {
// فعلاً سرویس ایمیل پیکربندی نشده؛ در محیط غیرپروداکشن کد لاگ می‌شود
this.logger.log(`[auth-otp-email] ${email} => ${code}`); this.logger.log(`[auth-otp-email] ${email} => ${code}`);
if (!returnOtp && this.config.get<string>('NODE_ENV') === 'production') { if (!this.isEmailOtpConfigured() && !returnOtp) {
// بدون ایمیل واقعی در پروداکشن، ورود/ثبت‌نام با ایمیل ممکن نیست مگر AUTH_RETURN_OTP
throw new BadRequestException( throw new BadRequestException(
'ارسال ایمیل پیکربندی نشده است؛ با پشتیبانی تماس بگیرید', 'ارسال ایمیل پیکربندی نشده است؛ با پشتیبانی تماس بگیرید',
); );
+27 -12
View File
@@ -78,11 +78,12 @@ async function submitOtp() {
error.value = ''; error.value = '';
loading.value = true; loading.value = true;
try { try {
await auth.verifyRegister( const mobile = mobileCode.value.trim();
challenge.value.challengeId, const email =
emailCode.value.trim(), challenge.value.emailOtpRequired === false
mobileCode.value.trim(), ? mobile
); : emailCode.value.trim();
await auth.verifyRegister(challenge.value.challengeId, email, mobile);
await router.push('/'); await router.push('/');
} catch (e) { } catch (e) {
error.value = apiError(e, 'تأیید کد ناموفق بود'); error.value = apiError(e, 'تأیید کد ناموفق بود');
@@ -264,20 +265,30 @@ function backToForm() {
<template v-else> <template v-else>
<button type="button" class="back" @click="backToForm">بازگشت</button> <button type="button" class="back" @click="backToForm">بازگشت</button>
<h1>تأیید ایمیل و موبایل</h1> <h1>تأیید هویت</h1>
<p class="lead"> <p class="lead">
کدهای یکبارمصرف ارسال‌شده به <template v-if="challenge?.emailOtpRequired !== false">
{{ challenge?.maskedEmail }} و {{ challenge?.maskedMobile }} را وارد کنید. کدهای یکبارمصرف ارسال‌شده به
{{ challenge?.maskedEmail }} و {{ challenge?.maskedMobile }} را وارد کنید.
</template>
<template v-else>
کد یکبارمصرف ارسال‌شده به {{ challenge?.maskedMobile }} را وارد کنید.
</template>
</p> </p>
<p v-if="challenge?.devEmailCode || challenge?.devMobileCode" class="dev-hint"> <p v-if="challenge?.devEmailCode || challenge?.devMobileCode" class="dev-hint">
کد ایمیل: <strong>{{ challenge?.devEmailCode }}</strong> <template v-if="challenge?.emailOtpRequired !== false">
— کد ایمیل: <strong>{{ challenge?.devEmailCode }}</strong>
—
</template>
کد موبایل: <strong>{{ challenge?.devMobileCode }}</strong> کد موبایل: <strong>{{ challenge?.devMobileCode }}</strong>
</p> </p>
<form class="form" @submit.prevent="submitOtp"> <form class="form" @submit.prevent="submitOtp">
<div class="grid-2"> <div
<div class="field"> class="grid-2"
:class="{ 'single-otp': challenge?.emailOtpRequired === false }"
>
<div v-if="challenge?.emailOtpRequired !== false" class="field">
<label for="emailCode">کد ایمیل</label> <label for="emailCode">کد ایمیل</label>
<input <input
id="emailCode" id="emailCode"
@@ -599,6 +610,10 @@ h1 {
.grid-2 { .grid-2 {
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
} }
.grid-2.single-otp {
grid-template-columns: 1fr;
}
} }
@media (min-width: 900px) { @media (min-width: 900px) {
+2
View File
@@ -15,6 +15,8 @@ UPLOAD_DIR=/var/www/crm/uploads
# در production کد OTP را در JSON برنگردانید # در production کد OTP را در JSON برنگردانید
PORTAL_SHOW_OTP=false PORTAL_SHOW_OTP=false
AUTH_RETURN_OTP=false AUTH_RETURN_OTP=false
# تا وقتی SMTP واقعی ندارید false بماند؛ OTP ورود/ثبت‌نام با پیامک می‌رود
AUTH_EMAIL_OTP_ENABLED=false
# SMS — پنل ترِز / رایگان (همان الگوی montime) # SMS — پنل ترِز / رایگان (همان الگوی montime)
SMS_PROVIDER=trez SMS_PROVIDER=trez
+2
View File
@@ -96,6 +96,8 @@ export interface RegisterChallengeResponse {
expiresInSeconds: number; expiresInSeconds: number;
maskedEmail?: string; maskedEmail?: string;
maskedMobile?: string; maskedMobile?: string;
/** وقتی سرویس ایمیل خاموش است false — فقط OTP موبایل لازم است */
emailOtpRequired?: boolean;
devEmailCode?: string; devEmailCode?: string;
devMobileCode?: string; devMobileCode?: string;
} }