авторизация по телефону
This commit is contained in:
parent
4112645f5e
commit
053bd699f3
|
|
@ -1,30 +1,4 @@
|
||||||
import { Controller, Request, Post, UseGuards } from '@nestjs/common';
|
import { Controller } from '@nestjs/common';
|
||||||
import { ApiBody } from '@nestjs/swagger';
|
|
||||||
import { LocalAuthGuard } from './auth/local-auth.guard';
|
|
||||||
import { AuthService } from './auth/auth.service';
|
|
||||||
|
|
||||||
@Controller()
|
@Controller()
|
||||||
export class AppController {
|
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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 { Module } from '@nestjs/common';
|
||||||
import { AuthService } from './auth.service';
|
import { AuthService } from './auth.service';
|
||||||
import { LocalStrategy } from './local.strategy';
|
import { AuthController } from './auth.controller';
|
||||||
import { UsersModule } from '../users/users.module';
|
import { UsersModule } from '../users/users.module';
|
||||||
import { PassportModule } from '@nestjs/passport';
|
|
||||||
import { JwtModule } from '@nestjs/jwt';
|
import { JwtModule } from '@nestjs/jwt';
|
||||||
import { jwtConstants } from './constants';
|
import { jwtConstants } from './constants';
|
||||||
|
import { VerificationStoreService } from './verification-store.service';
|
||||||
|
import { SmsService } from './sms.service';
|
||||||
|
import { RefreshTokenStoreService } from './refresh-token-store.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
UsersModule,
|
UsersModule,
|
||||||
PassportModule,
|
|
||||||
JwtModule.register({
|
JwtModule.register({
|
||||||
secret: jwtConstants.secret,
|
secret: jwtConstants.secret,
|
||||||
signOptions: { expiresIn: '90d' },
|
signOptions: { expiresIn: jwtConstants.accessTokenExpiresIn },
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
providers: [AuthService, LocalStrategy],
|
controllers: [AuthController],
|
||||||
|
providers: [AuthService, VerificationStoreService, SmsService, RefreshTokenStoreService],
|
||||||
exports: [AuthService],
|
exports: [AuthService],
|
||||||
})
|
})
|
||||||
export class AuthModule {}
|
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 { UsersService } from '../users/users.service';
|
||||||
import { User } from 'src/users/user.entity';
|
import { User } from 'src/users/user.entity';
|
||||||
import { JwtService } from '@nestjs/jwt';
|
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()
|
@Injectable()
|
||||||
export class AuthService {
|
export class AuthService {
|
||||||
|
|
@ -10,29 +24,134 @@ export class AuthService {
|
||||||
constructor(
|
constructor(
|
||||||
private usersService: UsersService,
|
private usersService: UsersService,
|
||||||
private jwtService: JwtService,
|
private jwtService: JwtService,
|
||||||
|
private verificationStore: VerificationStoreService,
|
||||||
|
private smsService: SmsService,
|
||||||
|
private refreshTokenStore: RefreshTokenStoreService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async validateUser(
|
/** Отправить код верификации на номер. Лимиты: 1/60с на номер, 3/15мин на номер, 10/15мин на IP. */
|
||||||
email: string,
|
async sendVerificationCode(phone: string, ip: string): Promise<{ success: true }> {
|
||||||
pass: string,
|
const normalizedPhone = this.normalizePhone(phone);
|
||||||
): Promise<Omit<User, 'password'> | null> {
|
if (!normalizedPhone) {
|
||||||
const user = await this.usersService.findOne(email);
|
throw new BadRequestException('Некорректный номер телефона');
|
||||||
if (user && user.password === pass) {
|
|
||||||
const { password, ...result } = user;
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
login(user: Omit<User, 'password'>) {
|
if (!this.verificationStore.canSendToPhone(normalizedPhone)) {
|
||||||
this.logger.log('LOG');
|
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 = {
|
const payload = {
|
||||||
username: user.email,
|
|
||||||
sub: user.userId,
|
sub: user.userId,
|
||||||
userId: user.userId,
|
userId: user.userId,
|
||||||
|
phone: user.phone,
|
||||||
};
|
};
|
||||||
return {
|
const access_token = this.jwtService.sign(payload, {
|
||||||
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 = {
|
export const jwtConstants = {
|
||||||
secret: '6by876hiuGHiugiuG8t78t87tGUYUYg8u7g87',
|
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;
|
lastName: string;
|
||||||
phone: string;
|
phone: string;
|
||||||
email: string;
|
email: string;
|
||||||
password: string;
|
/** Не задаётся при регистрации по телефону */
|
||||||
|
password?: string;
|
||||||
yachts?: Yacht[];
|
yachts?: Yacht[];
|
||||||
companyName?: string;
|
companyName?: string;
|
||||||
inn?: number;
|
inn?: number;
|
||||||
|
|
|
||||||
|
|
@ -66,6 +66,34 @@ export class UsersService {
|
||||||
return user;
|
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(
|
async findById(
|
||||||
userId: number,
|
userId: number,
|
||||||
includeYachts: boolean = false,
|
includeYachts: boolean = false,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue