Prefer SMS for auth OTP when email mailer is not configured.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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(
|
||||||
'ارسال ایمیل پیکربندی نشده است؛ با پشتیبانی تماس بگیرید',
|
'ارسال ایمیل پیکربندی نشده است؛ با پشتیبانی تماس بگیرید',
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user