diff --git a/apps/api/src/auth/auth.controller.ts b/apps/api/src/auth/auth.controller.ts index 0356b7c..dc6e39d 100644 --- a/apps/api/src/auth/auth.controller.ts +++ b/apps/api/src/auth/auth.controller.ts @@ -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) diff --git a/apps/api/src/auth/auth.service.ts b/apps/api/src/auth/auth.service.ts index 27a3fdb..ce98684 100644 --- a/apps/api/src/auth/auth.service.ts +++ b/apps/api/src/auth/auth.service.ts @@ -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 { 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 { 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 { + async verifyLoginOtp(dto: LoginOtpVerifyDto): Promise { 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 { 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('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, diff --git a/apps/api/src/auth/dto.ts b/apps/api/src/auth/dto.ts index ee15c92..1b7f442 100644 --- a/apps/api/src/auth/dto.ts +++ b/apps/api/src/auth/dto.ts @@ -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) diff --git a/apps/web/src/stores/auth.ts b/apps/web/src/stores/auth.ts index 3d62305..d2f7ec7 100644 --- a/apps/web/src/stores/auth.ts +++ b/apps/web/src/stores/auth.ts @@ -45,16 +45,23 @@ export const useAuthStore = defineStore('auth', () => { clearLegacy(); } - async function startLogin(identifier: string, password: string) { - const { data } = await api.post('/auth/login', { + async function login(identifier: string, password: string) { + const { data } = await api.post('/auth/login', { identifier, password, }); + persist(data); + } + + async function requestLoginOtp(mobile: string) { + const { data } = await api.post('/auth/login/otp', { + mobile, + }); return data; } - async function verifyLogin(challengeId: string, code: string) { - const { data } = await api.post('/auth/login/verify', { + async function verifyLoginOtp(challengeId: string, code: string) { + const { data } = await api.post('/auth/login/otp/verify', { challengeId, code, }); @@ -71,13 +78,13 @@ export const useAuthStore = defineStore('auth', () => { async function verifyRegister( challengeId: string, - emailCode: string, mobileCode: string, + emailCode?: string, ) { const { data } = await api.post('/auth/register/verify', { challengeId, - emailCode, mobileCode, + ...(emailCode ? { emailCode } : {}), }); persist(data); } @@ -94,8 +101,9 @@ export const useAuthStore = defineStore('auth', () => { token, user, isAuthenticated, - startLogin, - verifyLogin, + login, + requestLoginOtp, + verifyLoginOtp, startRegister, verifyRegister, logout, diff --git a/apps/web/src/views/LoginView.vue b/apps/web/src/views/LoginView.vue index fe49dce..3c36bd2 100644 --- a/apps/web/src/views/LoginView.vue +++ b/apps/web/src/views/LoginView.vue @@ -11,9 +11,13 @@ const auth = useAuthStore(); const router = useRouter(); const route = useRoute(); -const step = ref<'credentials' | 'otp'>('credentials'); +/** password = ورود با رمز | otp = ورود با کد موبایل */ +const mode = ref<'password' | 'otp'>('password'); +const otpStep = ref<'mobile' | 'code'>('mobile'); + const identifier = ref('owner@demo.local'); const password = ref('demo1234'); +const mobile = ref(''); const showPassword = ref(false); const remember = ref(true); const otpCode = ref(''); @@ -22,11 +26,8 @@ const error = ref(''); const loading = ref(false); const otpHint = computed(() => { - if (!challenge.value) return ''; - const dest = challenge.value.maskedDestination ?? ''; - return challenge.value.channel === 'email' - ? `کد ارسال‌شده به ایمیل ${dest} را وارد کنید` - : `کد ارسال‌شده به موبایل ${dest} را وارد کنید`; + const dest = challenge.value?.maskedDestination ?? ''; + return dest ? `کد ارسال‌شده به ${dest} را وارد کنید` : 'کد یکبارمصرف را وارد کنید'; }); function apiError(e: unknown, fallback: string) { @@ -40,15 +41,17 @@ function apiError(e: unknown, fallback: string) { return fallback; } -async function submitCredentials() { +async function goHome() { + const redirect = typeof route.query.redirect === 'string' ? route.query.redirect : '/'; + await router.push(redirect); +} + +async function submitPasswordLogin() { error.value = ''; loading.value = true; try { - challenge.value = await auth.startLogin(identifier.value.trim(), password.value); - if (challenge.value.devCode) { - otpCode.value = challenge.value.devCode; - } - step.value = 'otp'; + await auth.login(identifier.value.trim(), password.value); + await goHome(); } catch (e) { error.value = apiError(e, 'ورود ناموفق بود'); } finally { @@ -56,14 +59,27 @@ async function submitCredentials() { } } -async function submitOtp() { +async function submitOtpMobile() { + error.value = ''; + loading.value = true; + try { + challenge.value = await auth.requestLoginOtp(mobile.value.trim()); + if (challenge.value.devCode) otpCode.value = challenge.value.devCode; + otpStep.value = 'code'; + } catch (e) { + error.value = apiError(e, 'ارسال کد ناموفق بود'); + } finally { + loading.value = false; + } +} + +async function submitOtpCode() { if (!challenge.value) return; error.value = ''; loading.value = true; try { - await auth.verifyLogin(challenge.value.challengeId, otpCode.value.trim()); - const redirect = typeof route.query.redirect === 'string' ? route.query.redirect : '/'; - await router.push(redirect); + await auth.verifyLoginOtp(challenge.value.challengeId, otpCode.value.trim()); + await goHome(); } catch (e) { error.value = apiError(e, 'تأیید کد ناموفق بود'); } finally { @@ -71,8 +87,16 @@ async function submitOtp() { } } -function backToCredentials() { - step.value = 'credentials'; +function switchMode(next: 'password' | 'otp') { + mode.value = next; + otpStep.value = 'mobile'; + challenge.value = null; + otpCode.value = ''; + error.value = ''; +} + +function backToOtpMobile() { + otpStep.value = 'mobile'; otpCode.value = ''; challenge.value = null; error.value = ''; @@ -92,11 +116,32 @@ function backToCredentials() {
-