diff --git a/apps/api-gateway/src/grpc-exception.filter.ts b/apps/api-gateway/src/grpc-exception.filter.ts index 7a6375d..d2037d7 100644 --- a/apps/api-gateway/src/grpc-exception.filter.ts +++ b/apps/api-gateway/src/grpc-exception.filter.ts @@ -19,6 +19,8 @@ function grpcToHttp(code?: number): number { return 404; case GrpcStatus.ALREADY_EXISTS: return 409; + case GrpcStatus.RESOURCE_EXHAUSTED: + return 429; default: return 500; } diff --git a/apps/frontend/app/auth/login/page.tsx b/apps/frontend/app/auth/login/page.tsx index 107e452..509bccd 100644 --- a/apps/frontend/app/auth/login/page.tsx +++ b/apps/frontend/app/auth/login/page.tsx @@ -30,10 +30,18 @@ import { PhoneInput, phoneCountries, toE164 } from '@/components/ui/phone-input' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; -import type { AuthTokens, LoginMethod, OtpChannel } from '@/lib/api'; +import type { AuthTokens, LoginMethod, OtpChannel, OtpSendResponse } from '@/lib/api'; import { createQrLoginSession, pollQrLoginSession } from '@/lib/api'; +import { + clearOtpResendAvailableAt, + formatCooldownSeconds, + readOtpResendAvailableAt, + useOtpResendCooldown, + writeOtpResendAvailableAt +} from '@/hooks/use-otp-resend-cooldown'; + type Step = 'identify' | 'otp' | 'otpChannel' | 'password' | 'pin' | 'ldap' | 'qr' | 'totp'; @@ -129,6 +137,10 @@ export default function LoginPage() { const [qrExpiresAt, setQrExpiresAt] = useState(null); const [qrLoading, setQrLoading] = useState(false); + const [lastOtpChannel, setLastOtpChannel] = useState(); + const [otpResendAvailableAt, setOtpResendAvailableAt] = useState(null); + const otpResendSecondsLeft = useOtpResendCooldown(otpResendAvailableAt); + const canResendOtp = otpResendSecondsLeft <= 0 && !isSubmitting; @@ -180,34 +192,34 @@ export default function LoginPage() { + function applyOtpSendResponse(response: OtpSendResponse, loginRecipient: string, channel?: string) { + setMaskedTarget(response.maskedTarget); + setLastOtpChannel(channel); + setOtp(''); + setShowMethods(false); + const resendAt = response.resendAvailableAt ?? new Date(Date.now() + 60_000).toISOString(); + setOtpResendAvailableAt(resendAt); + writeOtpResendAvailableAt(loginRecipient, resendAt); + setStep('otp'); + } + async function requestLoginOtp(channel?: string, recipientOverride?: string) { - setIsSubmitting(true); - const loginRecipient = recipientOverride ?? recipient; try { - - const masked = await sendLoginOtp(loginRecipient, channel); - - setMaskedTarget(masked); - - setOtp(''); - - setShowMethods(false); - - setStep('otp'); - + const response = await sendLoginOtp(loginRecipient, channel); + applyOtpSendResponse(response, loginRecipient, channel); } catch (error) { - showToast(error instanceof Error ? error.message : 'Не удалось отправить код'); - } finally { - setIsSubmitting(false); - } + } + async function handleResendOtp() { + if (!canResendOtp) return; + await requestLoginOtp(lastOtpChannel, recipient); } @@ -230,6 +242,14 @@ export default function LoginPage() { + useEffect(() => { + if (step !== 'otp' || !recipient) return; + const saved = readOtpResendAvailableAt(recipient); + if (saved && new Date(saved).getTime() > Date.now()) { + setOtpResendAvailableAt(saved); + } + }, [recipient, step]); + const startQrLogin = useCallback(async () => { setQrLoading(true); @@ -618,6 +638,10 @@ export default function LoginPage() { setQrExpiresAt(null); + clearOtpResendAvailableAt(recipient); + setOtpResendAvailableAt(null); + setLastOtpChannel(undefined); + } @@ -1022,6 +1046,22 @@ export default function LoginPage() { {isSubmitting ?

Проверяем код...

: null} +
+ {canResendOtp ? ( + + ) : ( +

+ Запросить новый код через {formatCooldownSeconds(otpResendSecondsLeft)} +

+ )} +
+ {renderAlternativeMethods(false)} {totpChallengeToken ? ( diff --git a/apps/frontend/app/auth/register/page.tsx b/apps/frontend/app/auth/register/page.tsx index df8ad2a..d57a843 100644 --- a/apps/frontend/app/auth/register/page.tsx +++ b/apps/frontend/app/auth/register/page.tsx @@ -51,8 +51,8 @@ export default function RegisterPage() { try { const identifier = getIdentifier(); setRecipient(identifier); - const masked = await sendLoginOtp(identifier); - setMaskedTarget(masked); + const response = await sendLoginOtp(identifier); + setMaskedTarget(response.maskedTarget); setOtp(''); setStep('otp'); showToast('Код отправлен. Введите его ниже.'); diff --git a/apps/frontend/components/id/auth-provider.tsx b/apps/frontend/components/id/auth-provider.tsx index fefb673..4cabb7b 100644 --- a/apps/frontend/components/id/auth-provider.tsx +++ b/apps/frontend/components/id/auth-provider.tsx @@ -35,7 +35,7 @@ interface AuthContextValue { hasStoredSession: boolean; login: (login: string, password: string) => Promise; identifyLogin: (login: string) => Promise; - sendLoginOtp: (recipient: string, channel?: string) => Promise; + sendLoginOtp: (recipient: string, channel?: string) => Promise; verifyLoginOtp: (recipient: string, code: string) => Promise; loginWithPassword: (login: string, password: string) => Promise; loginWithLdap: (username: string, password: string) => Promise; @@ -308,11 +308,10 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { }, []); const sendLoginOtp = React.useCallback(async (recipient: string, channel?: string) => { - const response = await apiFetch('/auth/otp/send', { + return apiFetch('/auth/otp/send', { method: 'POST', body: JSON.stringify({ recipient, channel }) }); - return response.maskedTarget; }, []); const verifyLoginOtp = React.useCallback( diff --git a/apps/frontend/hooks/use-otp-resend-cooldown.ts b/apps/frontend/hooks/use-otp-resend-cooldown.ts new file mode 100644 index 0000000..81a2621 --- /dev/null +++ b/apps/frontend/hooks/use-otp-resend-cooldown.ts @@ -0,0 +1,59 @@ +'use client'; + +import { useEffect, useState } from 'react'; + +export function formatCooldownSeconds(seconds: number) { + const safe = Math.max(0, seconds); + const minutes = Math.floor(safe / 60); + const rest = safe % 60; + return `${minutes}:${String(rest).padStart(2, '0')}`; +} + +export function useOtpResendCooldown(resendAvailableAt: string | null) { + const [secondsLeft, setSecondsLeft] = useState(0); + + useEffect(() => { + if (!resendAvailableAt) { + setSecondsLeft(0); + return; + } + + const tick = () => { + const diff = Math.ceil((new Date(resendAvailableAt).getTime() - Date.now()) / 1000); + setSecondsLeft(Math.max(0, diff)); + }; + + tick(); + const timer = window.setInterval(tick, 1000); + return () => window.clearInterval(timer); + }, [resendAvailableAt]); + + return secondsLeft; +} + +export function readOtpResendAvailableAt(recipient: string) { + if (typeof window === 'undefined' || !recipient) return null; + try { + return sessionStorage.getItem(`otp-resend:${recipient.toLowerCase()}`); + } catch { + return null; + } +} + +export function writeOtpResendAvailableAt(recipient: string, resendAvailableAt: string) { + if (typeof window === 'undefined' || !recipient) return; + try { + sessionStorage.setItem(`otp-resend:${recipient.toLowerCase()}`, resendAvailableAt); + } catch { + // ignore quota errors + } +} + +export function clearOtpResendAvailableAt(recipient: string) { + if (typeof window === 'undefined' || !recipient) return; + try { + sessionStorage.removeItem(`otp-resend:${recipient.toLowerCase()}`); + } catch { + // ignore + } +} diff --git a/apps/frontend/lib/api.ts b/apps/frontend/lib/api.ts index 463efcc..cdec3a8 100644 --- a/apps/frontend/lib/api.ts +++ b/apps/frontend/lib/api.ts @@ -273,6 +273,7 @@ export interface OtpSendResponse { sent: boolean; expiresAt: string; maskedTarget: string; + resendAvailableAt?: string; } export interface PinVerificationResponse { diff --git a/apps/frontend/lib/system-settings-catalog.ts b/apps/frontend/lib/system-settings-catalog.ts index 01a0c85..a8eabca 100644 --- a/apps/frontend/lib/system-settings-catalog.ts +++ b/apps/frontend/lib/system-settings-catalog.ts @@ -38,6 +38,7 @@ export const SYSTEM_SETTING_CATALOG: SystemSettingMeta[] = [ { key: 'PIN_MIN_LENGTH', label: 'Мин. длина PIN', group: 'pin', type: 'number', unit: 'цифр' }, { key: 'PIN_MAX_LENGTH', label: 'Макс. длина PIN', group: 'pin', type: 'number', unit: 'цифр' }, { key: 'OTP_EXPIRY_MINUTES', label: 'Срок жизни OTP', group: 'auth', type: 'number', unit: 'мин' }, + { key: 'OTP_RESEND_COOLDOWN_SECONDS', label: 'Пауза перед повторной отправкой OTP', group: 'auth', type: 'number', unit: 'сек', hint: 'Минимальный интервал между запросами нового кода (обычно 60)' }, { key: 'OTP_MAX_ATTEMPTS', label: 'Попыток ввода OTP', group: 'auth', type: 'number' }, { key: 'ACCOUNT_DELETE_GRACE_DAYS', diff --git a/apps/sso-core/src/domain/auth.service.ts b/apps/sso-core/src/domain/auth.service.ts index 83d334b..ded0382 100644 --- a/apps/sso-core/src/domain/auth.service.ts +++ b/apps/sso-core/src/domain/auth.service.ts @@ -1,4 +1,4 @@ -import { BadRequestException, Injectable, UnauthorizedException } from '@nestjs/common'; +import { BadRequestException, HttpException, HttpStatus, Injectable, UnauthorizedException } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { JwtService } from '@nestjs/jwt'; import * as bcrypt from 'bcryptjs'; @@ -214,6 +214,30 @@ export class AuthService { async sendOtp(recipient: string, channel?: string, ipAddress?: string) { const existingUser = await this.findUserByRecipient(recipient); const target = this.normalizeOtpTarget(this.resolveOtpTarget(recipient, channel, existingUser)); + const cooldownSeconds = await this.settings.getNumber('OTP_RESEND_COOLDOWN_SECONDS', 60); + const resendKey = this.otpResendKey(target); + + const ttl = await this.redis.client.ttl(resendKey); + if (ttl > 0) { + throw new HttpException( + { + message: `Запросить новый код можно через ${this.formatOtpCooldown(ttl)}`, + code: 'OTP_RESEND_COOLDOWN', + resendAvailableAt: new Date(Date.now() + ttl * 1000).toISOString() + }, + HttpStatus.TOO_MANY_REQUESTS + ); + } + + await this.prisma.authCode.updateMany({ + where: { + target, + purpose: 'passwordless-login', + usedAt: null + }, + data: { usedAt: new Date() } + }); + const deliveryChannel = target.includes('@') ? 'email' : 'sms'; const expiryMinutes = await this.settings.getNumber('OTP_EXPIRY_MINUTES', 10); const code = String(randomInt(100000, 999999)); @@ -238,7 +262,9 @@ export class AuthService { await this.publishOtpPushNotification(existingUser.id, code, ipAddress, 'Вход в аккаунт'); } await this.redis.client.del(this.otpAttemptKey(target)); - return { sent: true, expiresAt: expiresAt.toISOString(), maskedTarget: this.maskTarget(target) }; + await this.redis.client.set(resendKey, '1', 'EX', cooldownSeconds); + const resendAvailableAt = new Date(Date.now() + cooldownSeconds * 1000).toISOString(); + return { sent: true, expiresAt: expiresAt.toISOString(), maskedTarget: this.maskTarget(target), resendAvailableAt }; } private async publishOtpPushNotification(userId: string, code: string, ipAddress: string | undefined, action: string) { @@ -329,6 +355,22 @@ export class AuthService { return `otp:attempts:passwordless-login:${target.toLowerCase()}`; } + private otpResendKey(target: string) { + return `otp:resend:passwordless-login:${target.toLowerCase()}`; + } + + private formatOtpCooldown(seconds: number) { + const minutes = Math.floor(seconds / 60); + const rest = seconds % 60; + if (minutes > 0 && rest > 0) { + return `${minutes}:${String(rest).padStart(2, '0')}`; + } + if (minutes > 0) { + return `${minutes} мин`; + } + return `${rest} сек`; + } + private maskTarget(value: string) { if (value.includes('@')) { const [name, domain] = value.split('@'); diff --git a/apps/sso-core/src/domain/system-settings.seed.ts b/apps/sso-core/src/domain/system-settings.seed.ts index c7367da..4fb87f8 100644 --- a/apps/sso-core/src/domain/system-settings.seed.ts +++ b/apps/sso-core/src/domain/system-settings.seed.ts @@ -17,6 +17,7 @@ export const DEFAULT_SYSTEM_SETTINGS = [ { key: 'PIN_MAX_LENGTH', value: '6', description: 'Максимальная длина PIN-кода' }, { key: 'MAX_FAMILY_MEMBERS', value: '6', description: 'Максимальное количество участников в семейной группе' }, { key: 'OTP_EXPIRY_MINUTES', value: '10', description: 'Время жизни OTP-кода для входа (минуты)' }, + { key: 'OTP_RESEND_COOLDOWN_SECONDS', value: '60', description: 'Минимальный интервал между повторными запросами OTP-кода (секунды)' }, { key: 'OTP_MAX_ATTEMPTS', value: '5', description: 'Максимальное количество попыток ввода OTP' }, { key: 'ACCOUNT_DELETE_GRACE_DAYS', diff --git a/apps/sso-core/src/infra/grpc-exception.filter.ts b/apps/sso-core/src/infra/grpc-exception.filter.ts index ed593cb..171b286 100644 --- a/apps/sso-core/src/infra/grpc-exception.filter.ts +++ b/apps/sso-core/src/infra/grpc-exception.filter.ts @@ -15,6 +15,8 @@ function httpToGrpc(code: number): number { return GrpcStatus.NOT_FOUND; case 409: return GrpcStatus.ALREADY_EXISTS; + case 429: + return GrpcStatus.RESOURCE_EXHAUSTED; default: return GrpcStatus.UNKNOWN; } diff --git a/shared/proto/auth.proto b/shared/proto/auth.proto index 1fd8c66..b0bfeff 100644 --- a/shared/proto/auth.proto +++ b/shared/proto/auth.proto @@ -70,6 +70,7 @@ message PasswordlessOtpResponse { bool sent = 1; string expiresAt = 2; string maskedTarget = 3; + string resendAvailableAt = 4; } message PasswordlessVerifyRequest {