Use password login without OTP; keep OTP for signup and optional mobile login.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -2,7 +2,8 @@ import { Body, Controller, Get, Post, UseGuards } from '@nestjs/common';
|
||||
import { AuthService } from './auth.service';
|
||||
import {
|
||||
LoginDto,
|
||||
LoginVerifyOtpDto,
|
||||
LoginOtpRequestDto,
|
||||
LoginOtpVerifyDto,
|
||||
RegisterOrganizationDto,
|
||||
RegisterVerifyOtpDto,
|
||||
} from './dto';
|
||||
@@ -23,14 +24,22 @@ export class AuthController {
|
||||
return this.authService.verifyRegister(dto);
|
||||
}
|
||||
|
||||
/** ورود با رمز (بدون OTP) */
|
||||
@Post('login')
|
||||
login(@Body() dto: LoginDto) {
|
||||
return this.authService.loginLegacy(dto);
|
||||
return this.authService.loginWithPassword(dto);
|
||||
}
|
||||
|
||||
@Post('login/verify')
|
||||
loginVerify(@Body() dto: LoginVerifyOtpDto) {
|
||||
return this.authService.verifyLogin(dto);
|
||||
/** درخواست کد ورود با موبایل */
|
||||
@Post('login/otp')
|
||||
loginOtpRequest(@Body() dto: LoginOtpRequestDto) {
|
||||
return this.authService.requestLoginOtp(dto);
|
||||
}
|
||||
|
||||
/** تأیید کد ورود با موبایل */
|
||||
@Post('login/otp/verify')
|
||||
loginOtpVerify(@Body() dto: LoginOtpVerifyDto) {
|
||||
return this.authService.verifyLoginOtp(dto);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
|
||||
@@ -14,7 +14,8 @@ import { PrismaService } from '../prisma/prisma.service';
|
||||
import { SmsService } from '../common/sms.service';
|
||||
import {
|
||||
LoginDto,
|
||||
LoginVerifyOtpDto,
|
||||
LoginOtpRequestDto,
|
||||
LoginOtpVerifyDto,
|
||||
RegisterOrganizationDto,
|
||||
RegisterVerifyOtpDto,
|
||||
} from './dto';
|
||||
@@ -83,7 +84,11 @@ export class AuthService {
|
||||
private readonly sms: SmsService,
|
||||
) {}
|
||||
|
||||
/** مرحله ۱ ثبتنام: اعتبارسنجی + ارسال OTP ایمیل و موبایل */
|
||||
/**
|
||||
* ثبتنام مرحله ۱:
|
||||
* - همیشه OTP موبایل
|
||||
* - OTP ایمیل فقط وقتی AUTH_EMAIL_OTP_ENABLED=true
|
||||
*/
|
||||
async startRegister(dto: RegisterOrganizationDto) {
|
||||
if (dto.password !== dto.passwordConfirm) {
|
||||
throw new BadRequestException('تکرار رمز عبور مطابقت ندارد');
|
||||
@@ -112,8 +117,7 @@ export class AuthService {
|
||||
const returnOtp = this.shouldReturnOtp();
|
||||
const emailOtpEnabled = this.isEmailOtpConfigured();
|
||||
const mobileCode = generateOtp();
|
||||
// تا وقتی سرویس ایمیل نداریم، همان کد پیامک برای تأیید ایمیل هم پذیرفته میشود
|
||||
const emailCode = emailOtpEnabled ? generateOtp() : mobileCode;
|
||||
const emailCode = emailOtpEnabled ? generateOtp() : null;
|
||||
const passwordHash = await bcrypt.hash(dto.password, 10);
|
||||
const payload: RegisterPayload = {
|
||||
organizationName: dto.organizationName.trim(),
|
||||
@@ -137,12 +141,8 @@ export class AuthService {
|
||||
},
|
||||
});
|
||||
|
||||
if (emailOtpEnabled) {
|
||||
if (emailOtpEnabled && emailCode) {
|
||||
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);
|
||||
|
||||
@@ -154,14 +154,14 @@ export class AuthService {
|
||||
emailOtpRequired: emailOtpEnabled,
|
||||
...(returnOtp
|
||||
? {
|
||||
devEmailCode: emailCode,
|
||||
...(emailCode ? { devEmailCode: emailCode } : {}),
|
||||
devMobileCode: mobileCode,
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
/** مرحله ۲ ثبتنام: تأیید هر دو کد و ساخت سازمان */
|
||||
/** ثبتنام مرحله ۲: تأیید OTP و ساخت سازمان */
|
||||
async verifyRegister(dto: RegisterVerifyOtpDto): Promise<LoginResponse> {
|
||||
const challenge = await this.prisma.authChallenge.findUnique({
|
||||
where: { id: dto.challengeId },
|
||||
@@ -175,10 +175,17 @@ export class AuthService {
|
||||
throw new UnauthorizedException('کد تأیید نامعتبر یا منقضی است');
|
||||
}
|
||||
|
||||
const emailOk = challenge.emailCode === dto.emailCode.trim();
|
||||
const mobileOk = challenge.mobileCode === dto.mobileCode.trim();
|
||||
if (!emailOk || !mobileOk) {
|
||||
throw new UnauthorizedException('کد تأیید ایمیل یا موبایل نادرست است');
|
||||
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();
|
||||
@@ -221,7 +228,7 @@ export class AuthService {
|
||||
fullName: payload.fullName,
|
||||
passwordHash: challenge.passwordHash,
|
||||
role: 'owner',
|
||||
emailVerifiedAt: now,
|
||||
emailVerifiedAt: emailRequired ? now : null,
|
||||
mobileVerifiedAt: now,
|
||||
},
|
||||
},
|
||||
@@ -233,7 +240,7 @@ export class AuthService {
|
||||
where: { id: challenge.id },
|
||||
data: {
|
||||
consumedAt: now,
|
||||
emailVerified: true,
|
||||
emailVerified: emailRequired,
|
||||
mobileVerified: true,
|
||||
},
|
||||
});
|
||||
@@ -241,8 +248,8 @@ export class AuthService {
|
||||
return this.buildLoginResponse(organization.users[0]);
|
||||
}
|
||||
|
||||
/** مرحله ۱ ورود: ایمیل/موبایل + رمز → OTP ترجیحاً با پیامک */
|
||||
async startLogin(dto: LoginDto) {
|
||||
/** ورود با رمز: ایمیل یا موبایل + رمز → 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;
|
||||
@@ -264,44 +271,49 @@ export class AuthService {
|
||||
throw new UnauthorizedException('اطلاعات ورود نادرست است');
|
||||
}
|
||||
|
||||
const returnOtp = this.shouldReturnOtp();
|
||||
const channel = this.resolveLoginOtpChannel(user, returnOtp);
|
||||
return this.buildLoginResponse(user);
|
||||
}
|
||||
|
||||
await this.throttleChallenges('login', user.email, user.mobile);
|
||||
/** ورود اختیاری با موبایل + 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,
|
||||
channel: 'mobile',
|
||||
email: user.email,
|
||||
mobile: user.mobile,
|
||||
passwordHash: null,
|
||||
mobile,
|
||||
payload: { userId: user.id } as Prisma.InputJsonValue,
|
||||
emailCode: channel === 'email' ? code : null,
|
||||
mobileCode: channel === 'mobile' ? code : null,
|
||||
mobileCode: code,
|
||||
expiresAt: new Date(Date.now() + OTP_TTL_SECONDS * 1000),
|
||||
},
|
||||
});
|
||||
|
||||
if (channel === 'email') {
|
||||
await this.dispatchEmailOtp(user.email, code, returnOtp);
|
||||
} else {
|
||||
await this.dispatchMobileOtp(user.mobile!, code, returnOtp);
|
||||
}
|
||||
await this.dispatchMobileOtp(mobile, code, returnOtp);
|
||||
|
||||
return {
|
||||
challengeId: challenge.id,
|
||||
channel,
|
||||
channel: 'mobile' as const,
|
||||
expiresInSeconds: OTP_TTL_SECONDS,
|
||||
maskedDestination:
|
||||
channel === 'email' ? maskEmail(user.email) : maskMobile(user.mobile!),
|
||||
maskedDestination: maskMobile(mobile),
|
||||
...(returnOtp ? { devCode: code } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/** مرحله ۲ ورود: تأیید OTP */
|
||||
async verifyLogin(dto: LoginVerifyOtpDto): Promise<LoginResponse> {
|
||||
async verifyLoginOtp(dto: LoginOtpVerifyDto): Promise<LoginResponse> {
|
||||
const challenge = await this.prisma.authChallenge.findUnique({
|
||||
where: { id: dto.challengeId },
|
||||
});
|
||||
@@ -314,20 +326,16 @@ export class AuthService {
|
||||
throw new UnauthorizedException('کد تأیید نامعتبر یا منقضی است');
|
||||
}
|
||||
|
||||
const code = dto.code.trim();
|
||||
const expected =
|
||||
challenge.channel === 'email' ? challenge.emailCode : challenge.mobileCode;
|
||||
if (!expected || expected !== code) {
|
||||
if (!challenge.mobileCode || challenge.mobileCode !== dto.code.trim()) {
|
||||
throw new UnauthorizedException('کد تأیید نادرست است');
|
||||
}
|
||||
|
||||
const payload = (challenge.payload ?? {}) as { userId?: string };
|
||||
const userId = payload.userId;
|
||||
if (!userId) {
|
||||
if (!payload.userId) {
|
||||
throw new BadRequestException('چالش ورود ناقص است');
|
||||
}
|
||||
|
||||
const user = await this.prisma.user.findUnique({ where: { id: userId } });
|
||||
const user = await this.prisma.user.findUnique({ where: { id: payload.userId } });
|
||||
if (!user) {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
@@ -335,29 +343,18 @@ export class AuthService {
|
||||
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 },
|
||||
data: { consumedAt: now, mobileVerified: true },
|
||||
});
|
||||
if (!user.mobileVerifiedAt) {
|
||||
await this.prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { 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) {
|
||||
@@ -398,37 +395,10 @@ 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(
|
||||
email: string,
|
||||
code: string,
|
||||
|
||||
@@ -10,13 +10,11 @@ import {
|
||||
import { BUSINESS_FIELDS } from '@mento/shared';
|
||||
|
||||
export class LoginDto {
|
||||
/** ایمیل یا موبایل — کلاینت جدید */
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(3)
|
||||
identifier?: string;
|
||||
|
||||
/** سازگاری با کلاینت قدیمی */
|
||||
@IsOptional()
|
||||
@IsEmail()
|
||||
email?: string;
|
||||
@@ -26,7 +24,15 @@ export class LoginDto {
|
||||
password!: string;
|
||||
}
|
||||
|
||||
export class LoginVerifyOtpDto {
|
||||
export class LoginOtpRequestDto {
|
||||
@IsString()
|
||||
@Matches(/^09\d{9}$|^\+98\d{10}$|^98\d{10}$|^9\d{9}$/, {
|
||||
message: 'شماره موبایل معتبر نیست',
|
||||
})
|
||||
mobile!: string;
|
||||
}
|
||||
|
||||
export class LoginOtpVerifyDto {
|
||||
@IsString()
|
||||
challengeId!: string;
|
||||
|
||||
@@ -77,9 +83,10 @@ export class RegisterVerifyOtpDto {
|
||||
@IsString()
|
||||
challengeId!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(4)
|
||||
emailCode!: string;
|
||||
emailCode?: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(4)
|
||||
|
||||
Reference in New Issue
Block a user