авторизация по телефону
This commit is contained in:
parent
4112645f5e
commit
053bd699f3
|
|
@ -1,30 +1,4 @@
|
|||
import { Controller, Request, Post, UseGuards } from '@nestjs/common';
|
||||
import { ApiBody } from '@nestjs/swagger';
|
||||
import { LocalAuthGuard } from './auth/local-auth.guard';
|
||||
import { AuthService } from './auth/auth.service';
|
||||
import { Controller } from '@nestjs/common';
|
||||
|
||||
@Controller()
|
||||
export class AppController {
|
||||
constructor(private authService: AuthService) {}
|
||||
|
||||
@UseGuards(LocalAuthGuard)
|
||||
@Post('auth/login')
|
||||
@ApiBody({
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
email: { type: 'string', example: 'emai1@email.com' },
|
||||
password: { type: 'string', example: 'admin' },
|
||||
},
|
||||
},
|
||||
})
|
||||
async login(@Request() req) {
|
||||
return this.authService.login(req.user);
|
||||
}
|
||||
|
||||
@UseGuards(LocalAuthGuard)
|
||||
@Post('auth/logout')
|
||||
async logout(@Request() req) {
|
||||
return req.logout();
|
||||
}
|
||||
}
|
||||
export class AppController {}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,51 @@
|
|||
import { Body, Controller, Post, Req, UsePipes, ValidationPipe } from '@nestjs/common';
|
||||
import { ApiBody, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||
import type { Request } from 'express';
|
||||
import { AuthService } from './auth.service';
|
||||
import { SendCodeDto } from './dto/send-code.dto';
|
||||
import { VerifyCodeDto } from './dto/verify-code.dto';
|
||||
import { RefreshTokenDto } from './dto/refresh.dto';
|
||||
|
||||
function getClientIp(req: Request): string {
|
||||
const xff = req.headers['x-forwarded-for'];
|
||||
if (typeof xff === 'string') return xff.split(',')[0].trim();
|
||||
if (Array.isArray(xff) && xff[0]) return xff[0].trim();
|
||||
return req.socket?.remoteAddress ?? req.ip ?? '127.0.0.1';
|
||||
}
|
||||
|
||||
@ApiTags('auth')
|
||||
@Controller('auth')
|
||||
@UsePipes(new ValidationPipe({ whitelist: true }))
|
||||
export class AuthController {
|
||||
constructor(private readonly authService: AuthService) {}
|
||||
|
||||
@Post('send-code')
|
||||
@ApiOperation({ summary: 'Создать код верификации и отправить по SMS' })
|
||||
@ApiBody({ type: SendCodeDto })
|
||||
@ApiResponse({ status: 201, description: 'Код отправлен' })
|
||||
@ApiResponse({ status: 429, description: 'Превышены лимиты отправки' })
|
||||
async sendCode(@Body() dto: SendCodeDto, @Req() req: Request) {
|
||||
const ip = getClientIp(req);
|
||||
return this.authService.sendVerificationCode(dto.phone, ip);
|
||||
}
|
||||
|
||||
@Post('verify-code')
|
||||
@ApiOperation({ summary: 'Проверить код и получить access/refresh токены' })
|
||||
@ApiBody({ type: VerifyCodeDto })
|
||||
@ApiResponse({ status: 201, description: 'Токены выданы', schema: { properties: { access_token: { type: 'string' }, refresh_token: { type: 'string' } } } })
|
||||
@ApiResponse({ status: 400, description: 'Неверный или просроченный код' })
|
||||
@ApiResponse({ status: 429, description: 'Блокировка после 5 неверных попыток' })
|
||||
async verifyCode(@Body() dto: VerifyCodeDto, @Req() req: Request) {
|
||||
const ip = getClientIp(req);
|
||||
return this.authService.verifyCode(dto.phone, dto.code, ip);
|
||||
}
|
||||
|
||||
@Post('refresh')
|
||||
@ApiOperation({ summary: 'Обновить access токен по refresh токену' })
|
||||
@ApiBody({ type: RefreshTokenDto })
|
||||
@ApiResponse({ status: 201, description: 'Новые токены выданы' })
|
||||
@ApiResponse({ status: 400, description: 'Недействительный refresh token' })
|
||||
async refresh(@Body() dto: RefreshTokenDto) {
|
||||
return this.authService.refresh(dto.refresh_token);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,21 +1,23 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { AuthService } from './auth.service';
|
||||
import { LocalStrategy } from './local.strategy';
|
||||
import { AuthController } from './auth.controller';
|
||||
import { UsersModule } from '../users/users.module';
|
||||
import { PassportModule } from '@nestjs/passport';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { jwtConstants } from './constants';
|
||||
import { VerificationStoreService } from './verification-store.service';
|
||||
import { SmsService } from './sms.service';
|
||||
import { RefreshTokenStoreService } from './refresh-token-store.service';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
UsersModule,
|
||||
PassportModule,
|
||||
JwtModule.register({
|
||||
secret: jwtConstants.secret,
|
||||
signOptions: { expiresIn: '90d' },
|
||||
signOptions: { expiresIn: jwtConstants.accessTokenExpiresIn },
|
||||
}),
|
||||
],
|
||||
providers: [AuthService, LocalStrategy],
|
||||
controllers: [AuthController],
|
||||
providers: [AuthService, VerificationStoreService, SmsService, RefreshTokenStoreService],
|
||||
exports: [AuthService],
|
||||
})
|
||||
export class AuthModule {}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,21 @@
|
|||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import {
|
||||
BadRequestException,
|
||||
HttpException,
|
||||
HttpStatus,
|
||||
Injectable,
|
||||
Logger,
|
||||
} from '@nestjs/common';
|
||||
import { UsersService } from '../users/users.service';
|
||||
import { User } from 'src/users/user.entity';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { VerificationStoreService } from './verification-store.service';
|
||||
import { SmsService } from './sms.service';
|
||||
import { RefreshTokenStoreService } from './refresh-token-store.service';
|
||||
import { jwtConstants } from './constants';
|
||||
import {
|
||||
SMS_PER_IP_MAX,
|
||||
SMS_PER_PHONE_MAX,
|
||||
} from './verification.constants';
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
|
|
@ -10,29 +24,134 @@ export class AuthService {
|
|||
constructor(
|
||||
private usersService: UsersService,
|
||||
private jwtService: JwtService,
|
||||
private verificationStore: VerificationStoreService,
|
||||
private smsService: SmsService,
|
||||
private refreshTokenStore: RefreshTokenStoreService,
|
||||
) {}
|
||||
|
||||
async validateUser(
|
||||
email: string,
|
||||
pass: string,
|
||||
): Promise<Omit<User, 'password'> | null> {
|
||||
const user = await this.usersService.findOne(email);
|
||||
if (user && user.password === pass) {
|
||||
const { password, ...result } = user;
|
||||
return result;
|
||||
}
|
||||
return null;
|
||||
/** Отправить код верификации на номер. Лимиты: 1/60с на номер, 3/15мин на номер, 10/15мин на IP. */
|
||||
async sendVerificationCode(phone: string, ip: string): Promise<{ success: true }> {
|
||||
const normalizedPhone = this.normalizePhone(phone);
|
||||
if (!normalizedPhone) {
|
||||
throw new BadRequestException('Некорректный номер телефона');
|
||||
}
|
||||
|
||||
login(user: Omit<User, 'password'>) {
|
||||
this.logger.log('LOG');
|
||||
if (!this.verificationStore.canSendToPhone(normalizedPhone)) {
|
||||
throw new HttpException(
|
||||
'Повторная отправка кода возможна не чаще 1 раза в 60 секунд',
|
||||
HttpStatus.TOO_MANY_REQUESTS,
|
||||
);
|
||||
}
|
||||
if (this.verificationStore.getSendCountByPhone(normalizedPhone) >= SMS_PER_PHONE_MAX) {
|
||||
throw new HttpException(
|
||||
`Не более ${SMS_PER_PHONE_MAX} SMS на номер за 15 минут`,
|
||||
HttpStatus.TOO_MANY_REQUESTS,
|
||||
);
|
||||
}
|
||||
if (this.verificationStore.getSendCountByIp(ip) >= SMS_PER_IP_MAX) {
|
||||
throw new HttpException(
|
||||
`Превышен лимит запросов с вашего IP (${SMS_PER_IP_MAX} за 15 минут)`,
|
||||
HttpStatus.TOO_MANY_REQUESTS,
|
||||
);
|
||||
}
|
||||
|
||||
const code = this.generateCode();
|
||||
this.verificationStore.recordSend(normalizedPhone, ip, code);
|
||||
await this.smsService.sendVerificationCode(normalizedPhone, code);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
/** Проверить код и выдать access/refresh токены. При отсутствии номера в БД — создать пользователя. */
|
||||
async verifyCode(
|
||||
phone: string,
|
||||
code: string,
|
||||
ip: string,
|
||||
): Promise<{ access_token: string; refresh_token: string }> {
|
||||
const normalizedPhone = this.normalizePhone(phone);
|
||||
if (!normalizedPhone) {
|
||||
throw new BadRequestException('Некорректный номер телефона');
|
||||
}
|
||||
if (!code || code.length !== 4) {
|
||||
throw new BadRequestException('Код должен содержать 4 символа');
|
||||
}
|
||||
|
||||
if (this.verificationStore.isVerifyBlocked(normalizedPhone, ip)) {
|
||||
const remaining = this.verificationStore.getBlockedRemainingSec(normalizedPhone, ip);
|
||||
throw new HttpException(
|
||||
`Слишком много попыток. Попробуйте через ${remaining} секунд`,
|
||||
HttpStatus.TOO_MANY_REQUESTS,
|
||||
);
|
||||
}
|
||||
|
||||
const result = this.verificationStore.checkCode(normalizedPhone, ip, code);
|
||||
if (!result.ok) {
|
||||
if (result.reason === 'expired') {
|
||||
throw new BadRequestException('Код истёк. Запросите новый');
|
||||
}
|
||||
throw new BadRequestException(
|
||||
`Неверный код. Осталось ${result.remainingAttempts} попыток`,
|
||||
);
|
||||
}
|
||||
|
||||
let user = await this.usersService.findByPhone(normalizedPhone);
|
||||
if (!user) {
|
||||
user = await this.usersService.createByPhone(normalizedPhone);
|
||||
}
|
||||
|
||||
return this.issueTokens(user);
|
||||
}
|
||||
|
||||
/** Обновить access токен по refresh токену. */
|
||||
async refresh(refreshToken: string): Promise<{ access_token: string; refresh_token: string }> {
|
||||
const stored = this.refreshTokenStore.get(refreshToken);
|
||||
if (!stored) {
|
||||
throw new BadRequestException('Недействительный или просроченный refresh token');
|
||||
}
|
||||
try {
|
||||
this.jwtService.verify(refreshToken, { secret: jwtConstants.secret });
|
||||
} catch {
|
||||
this.refreshTokenStore.remove(refreshToken);
|
||||
throw new BadRequestException('Недействительный refresh token');
|
||||
}
|
||||
|
||||
const user = await this.usersService.findById(stored.userId);
|
||||
if (!user) {
|
||||
this.refreshTokenStore.remove(refreshToken);
|
||||
throw new BadRequestException('Пользователь не найден');
|
||||
}
|
||||
|
||||
this.refreshTokenStore.remove(refreshToken);
|
||||
return this.issueTokens(user);
|
||||
}
|
||||
|
||||
private issueTokens(user: User): { access_token: string; refresh_token: string } {
|
||||
const payload = {
|
||||
username: user.email,
|
||||
sub: user.userId,
|
||||
userId: user.userId,
|
||||
phone: user.phone,
|
||||
};
|
||||
return {
|
||||
access_token: this.jwtService.sign(payload),
|
||||
};
|
||||
const access_token = this.jwtService.sign(payload, {
|
||||
expiresIn: jwtConstants.accessTokenExpiresIn,
|
||||
});
|
||||
const refresh_token = this.jwtService.sign(
|
||||
{ ...payload, type: 'refresh' },
|
||||
{ expiresIn: jwtConstants.refreshTokenExpiresIn },
|
||||
);
|
||||
const decoded = this.jwtService.decode(refresh_token) as { exp: number };
|
||||
const expiresAt = decoded?.exp ? decoded.exp * 1000 : Date.now() + 30 * 24 * 60 * 60 * 1000;
|
||||
this.refreshTokenStore.set(refresh_token, user.userId, expiresAt);
|
||||
return { access_token, refresh_token };
|
||||
}
|
||||
|
||||
private normalizePhone(phone: string): string {
|
||||
const digits = phone.replace(/\D/g, '');
|
||||
if (digits.length < 10) return '';
|
||||
if (digits.length === 10 && digits.startsWith('9')) return '+7' + digits;
|
||||
if (digits.length === 11 && digits.startsWith('7')) return '+' + digits;
|
||||
return digits.startsWith('7') ? '+' + digits : '+' + digits;
|
||||
}
|
||||
|
||||
private generateCode(): string {
|
||||
return String(Math.floor(1000 + Math.random() * 9000));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,7 @@
|
|||
export const jwtConstants = {
|
||||
secret: '6by876hiuGHiugiuG8t78t87tGUYUYg8u7g87',
|
||||
/** Access token: 15 минут (в секундах) */
|
||||
accessTokenExpiresIn: 15 * 60,
|
||||
/** Refresh token: 30 дней (в секундах) */
|
||||
refreshTokenExpiresIn: 30 * 24 * 60 * 60,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,9 @@
|
|||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsString, IsNotEmpty } from 'class-validator';
|
||||
|
||||
export class RefreshTokenDto {
|
||||
@ApiProperty({ description: 'Refresh token' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
refresh_token: string;
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsString, Matches, MinLength } from 'class-validator';
|
||||
|
||||
export class SendCodeDto {
|
||||
@ApiProperty({ example: '+79001234567', description: 'Номер телефона' })
|
||||
@IsString()
|
||||
@MinLength(10, { message: 'Некорректный номер телефона' })
|
||||
@Matches(/^[\d+\s()-]+$/, { message: 'Некорректный формат номера' })
|
||||
phone: string;
|
||||
}
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsString, Length, Matches, MinLength } from 'class-validator';
|
||||
|
||||
export class VerifyCodeDto {
|
||||
@ApiProperty({ example: '+79001234567', description: 'Номер телефона' })
|
||||
@IsString()
|
||||
@MinLength(10, { message: 'Некорректный номер телефона' })
|
||||
@Matches(/^[\d+\s()-]+$/, { message: 'Некорректный формат номера' })
|
||||
phone: string;
|
||||
|
||||
@ApiProperty({ example: '1234', description: 'Код из 4 цифр', minLength: 4, maxLength: 4 })
|
||||
@IsString()
|
||||
@Length(4, 4, { message: 'Код должен содержать 4 символа' })
|
||||
@Matches(/^\d{4}$/, { message: 'Код должен состоять из 4 цифр' })
|
||||
code: string;
|
||||
}
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
|
||||
@Injectable()
|
||||
export class LocalAuthGuard extends AuthGuard('local') {}
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
import { Strategy } from 'passport-local';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { AuthService } from './auth.service';
|
||||
import type { User } from 'src/users/user.entity';
|
||||
|
||||
@Injectable()
|
||||
export class LocalStrategy extends PassportStrategy(Strategy) {
|
||||
constructor(private authService: AuthService) {
|
||||
super({
|
||||
usernameField: 'email',
|
||||
});
|
||||
}
|
||||
|
||||
async validate(
|
||||
email: string,
|
||||
password: string,
|
||||
): Promise<Omit<User, 'password'>> {
|
||||
const user = await this.authService.validateUser(email, password);
|
||||
|
||||
if (!user) {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
return user;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
interface StoredRefresh {
|
||||
userId: number;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class RefreshTokenStoreService {
|
||||
private readonly store = new Map<string, StoredRefresh>();
|
||||
private cleanupInterval: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
constructor() {
|
||||
this.cleanupInterval = setInterval(() => this.cleanup(), 60_000);
|
||||
}
|
||||
|
||||
private cleanup() {
|
||||
const now = Date.now();
|
||||
for (const [token, data] of this.store.entries()) {
|
||||
if (data.expiresAt < now) this.store.delete(token);
|
||||
}
|
||||
}
|
||||
|
||||
set(token: string, userId: number, expiresAt: number): void {
|
||||
this.store.set(token, { userId, expiresAt });
|
||||
}
|
||||
|
||||
get(token: string): { userId: number } | null {
|
||||
const data = this.store.get(token);
|
||||
if (!data || data.expiresAt < Date.now()) return null;
|
||||
return { userId: data.userId };
|
||||
}
|
||||
|
||||
remove(token: string): void {
|
||||
this.store.delete(token);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
export const SMS_SERVICE = 'SmsService';
|
||||
|
||||
/** Интерфейс для отправки SMS. В проде подставьте реализацию (SMS.ru, Twilio и т.д.). */
|
||||
export interface ISmsSender {
|
||||
sendVerificationCode(phone: string, code: string): Promise<void>;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class SmsService implements ISmsSender {
|
||||
private readonly logger = new Logger(SmsService.name);
|
||||
|
||||
async sendVerificationCode(phone: string, code: string): Promise<void> {
|
||||
// Заглушка: в разработке только логируем. Подключите реальный провайдер SMS.
|
||||
this.logger.log(`[SMS] Код для ${phone}: ${code}`);
|
||||
// await this.smsProvider.send(phone, `Ваш код: ${code}`);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,133 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import {
|
||||
CODE_TTL_SEC,
|
||||
MAX_VERIFY_ATTEMPTS,
|
||||
SMS_MIN_INTERVAL_SEC,
|
||||
SMS_PER_IP_MAX,
|
||||
SMS_PER_IP_WINDOW_SEC,
|
||||
SMS_PER_PHONE_MAX,
|
||||
SMS_PER_PHONE_WINDOW_SEC,
|
||||
VERIFY_BLOCK_DURATION_SEC,
|
||||
} from './verification.constants';
|
||||
|
||||
interface StoredCode {
|
||||
code: string;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
|
||||
interface FailedAttempts {
|
||||
count: number;
|
||||
blockedUntil: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class VerificationStoreService {
|
||||
private readonly codes = new Map<string, StoredCode>();
|
||||
private readonly lastSentAt = new Map<string, number>();
|
||||
private readonly sentByPhone: { at: number; phone: string }[] = [];
|
||||
private readonly sentByIp: { at: number; ip: string }[] = [];
|
||||
private readonly failedAttempts = new Map<string, FailedAttempts>();
|
||||
private cleanupInterval: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
constructor() {
|
||||
this.startCleanup();
|
||||
}
|
||||
|
||||
private startCleanup() {
|
||||
if (this.cleanupInterval) return;
|
||||
this.cleanupInterval = setInterval(() => this.cleanup(), 60_000);
|
||||
}
|
||||
|
||||
private cleanup() {
|
||||
const now = Date.now();
|
||||
const borderPhone = now - SMS_PER_PHONE_WINDOW_SEC * 1000;
|
||||
const borderIp = now - SMS_PER_IP_WINDOW_SEC * 1000;
|
||||
while (this.sentByPhone.length && this.sentByPhone[0].at < borderPhone) this.sentByPhone.shift();
|
||||
while (this.sentByIp.length && this.sentByIp[0].at < borderIp) this.sentByIp.shift();
|
||||
|
||||
for (const [phone, data] of this.codes.entries()) {
|
||||
if (data.createdAt + CODE_TTL_SEC * 1000 < now) this.codes.delete(phone);
|
||||
}
|
||||
|
||||
for (const [key, data] of this.failedAttempts.entries()) {
|
||||
if (data.blockedUntil < now) this.failedAttempts.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
/** Можно ли отправить SMS на номер (60 сек с последней отправки). */
|
||||
canSendToPhone(phone: string): boolean {
|
||||
const last = this.lastSentAt.get(phone);
|
||||
if (!last) return true;
|
||||
return Date.now() - last >= SMS_MIN_INTERVAL_SEC * 1000;
|
||||
}
|
||||
|
||||
/** Количество отправок на номер за последние 15 мин. */
|
||||
getSendCountByPhone(phone: string): number {
|
||||
const border = Date.now() - SMS_PER_PHONE_WINDOW_SEC * 1000;
|
||||
return this.sentByPhone.filter((r) => r.at >= border && r.phone === phone).length;
|
||||
}
|
||||
|
||||
/** Количество отправок с IP за последние 15 мин. */
|
||||
getSendCountByIp(ip: string): number {
|
||||
const border = Date.now() - SMS_PER_IP_WINDOW_SEC * 1000;
|
||||
return this.sentByIp.filter((r) => r.at >= border && r.ip === ip).length;
|
||||
}
|
||||
|
||||
/** Зарегистрировать отправку (инвалидировать старый код, записать лимиты). */
|
||||
recordSend(phone: string, ip: string, code: string): void {
|
||||
const now = Date.now();
|
||||
this.codes.set(phone, { code, createdAt: now });
|
||||
this.lastSentAt.set(phone, now);
|
||||
this.sentByPhone.push({ at: now, phone });
|
||||
this.sentByIp.push({ at: now, ip });
|
||||
this.cleanup();
|
||||
}
|
||||
|
||||
/** Результат проверки кода. */
|
||||
checkCode(
|
||||
phone: string,
|
||||
ip: string,
|
||||
code: string,
|
||||
): { ok: true } | { ok: false; reason: 'expired' } | { ok: false; reason: 'wrong'; remainingAttempts: number } {
|
||||
const key = `${phone}:${ip}`;
|
||||
const blocked = this.failedAttempts.get(key);
|
||||
if (blocked && blocked.blockedUntil > Date.now()) return { ok: false, reason: 'expired' };
|
||||
|
||||
const stored = this.codes.get(phone);
|
||||
if (!stored) return { ok: false, reason: 'expired' };
|
||||
if (stored.createdAt + CODE_TTL_SEC * 1000 < Date.now()) {
|
||||
this.codes.delete(phone);
|
||||
return { ok: false, reason: 'expired' };
|
||||
}
|
||||
if (stored.code !== code) {
|
||||
const current = this.failedAttempts.get(key) ?? { count: 0, blockedUntil: 0 };
|
||||
current.count += 1;
|
||||
if (current.count >= MAX_VERIFY_ATTEMPTS) {
|
||||
current.blockedUntil = Date.now() + VERIFY_BLOCK_DURATION_SEC * 1000;
|
||||
}
|
||||
this.failedAttempts.set(key, current);
|
||||
const remainingAttempts = Math.max(0, MAX_VERIFY_ATTEMPTS - current.count);
|
||||
return { ok: false, reason: 'wrong', remainingAttempts };
|
||||
}
|
||||
this.codes.delete(phone);
|
||||
this.failedAttempts.delete(key);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
/** Заблокирован ли ввод кода по phone+ip. */
|
||||
isVerifyBlocked(phone: string, ip: string): boolean {
|
||||
const key = `${phone}:${ip}`;
|
||||
const data = this.failedAttempts.get(key);
|
||||
if (!data) return false;
|
||||
return data.blockedUntil > Date.now();
|
||||
}
|
||||
|
||||
/** Оставшееся время блокировки в секундах (0 если не заблокирован). */
|
||||
getBlockedRemainingSec(phone: string, ip: string): number {
|
||||
const key = `${phone}:${ip}`;
|
||||
const data = this.failedAttempts.get(key);
|
||||
if (!data || data.blockedUntil <= Date.now()) return 0;
|
||||
return Math.ceil((data.blockedUntil - Date.now()) / 1000);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
/** Интервал между отправками на один номер (сек) */
|
||||
export const SMS_MIN_INTERVAL_SEC = 60;
|
||||
/** Окно для лимита SMS на номер (сек) */
|
||||
export const SMS_PER_PHONE_WINDOW_SEC = 15 * 60;
|
||||
/** Макс. SMS на один номер за окно */
|
||||
export const SMS_PER_PHONE_MAX = 3;
|
||||
/** Окно для лимита SMS по IP (сек) */
|
||||
export const SMS_PER_IP_WINDOW_SEC = 15 * 60;
|
||||
/** Макс. SMS с одного IP за окно */
|
||||
export const SMS_PER_IP_MAX = 10;
|
||||
/** Время жизни кода (сек) */
|
||||
export const CODE_TTL_SEC = 3 * 60;
|
||||
/** Макс. неудачных попыток ввода кода */
|
||||
export const MAX_VERIFY_ATTEMPTS = 5;
|
||||
/** Длительность блокировки после превышения попыток (сек) */
|
||||
export const VERIFY_BLOCK_DURATION_SEC = 15 * 60;
|
||||
|
|
@ -6,7 +6,8 @@ export type User = {
|
|||
lastName: string;
|
||||
phone: string;
|
||||
email: string;
|
||||
password: string;
|
||||
/** Не задаётся при регистрации по телефону */
|
||||
password?: string;
|
||||
yachts?: Yacht[];
|
||||
companyName?: string;
|
||||
inn?: number;
|
||||
|
|
|
|||
|
|
@ -66,6 +66,34 @@ export class UsersService {
|
|||
return user;
|
||||
}
|
||||
|
||||
async findByPhone(
|
||||
phone: string,
|
||||
includeYachts: boolean = false,
|
||||
): Promise<User | undefined> {
|
||||
const user = this.users.find((u) => u.phone === phone);
|
||||
if (user && includeYachts) {
|
||||
user.yachts = [];
|
||||
}
|
||||
return user;
|
||||
}
|
||||
|
||||
/** Создать пользователя по номеру телефона (при первой авторизации по коду). */
|
||||
async createByPhone(phone: string): Promise<User> {
|
||||
const maxId = this.users.length
|
||||
? Math.max(...this.users.map((u) => u.userId))
|
||||
: 0;
|
||||
const newUser: User = {
|
||||
userId: maxId + 1,
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
phone,
|
||||
email: '',
|
||||
yachts: [],
|
||||
};
|
||||
this.users.push(newUser);
|
||||
return newUser;
|
||||
}
|
||||
|
||||
async findById(
|
||||
userId: number,
|
||||
includeYachts: boolean = false,
|
||||
|
|
|
|||
Loading…
Reference in New Issue