diff --git a/src/app.controller.ts b/src/app.controller.ts index 352d9ff..3c21ebe 100644 --- a/src/app.controller.ts +++ b/src/app.controller.ts @@ -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 {} diff --git a/src/auth/auth.controller.ts b/src/auth/auth.controller.ts new file mode 100644 index 0000000..96f3c85 --- /dev/null +++ b/src/auth/auth.controller.ts @@ -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); + } +} diff --git a/src/auth/auth.module.ts b/src/auth/auth.module.ts index f3e6def..ab60a3d 100644 --- a/src/auth/auth.module.ts +++ b/src/auth/auth.module.ts @@ -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 {} diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts index c21be81..fcbdee5 100644 --- a/src/auth/auth.service.ts +++ b/src/auth/auth.service.ts @@ -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 | null> { - const user = await this.usersService.findOne(email); - if (user && user.password === pass) { - const { password, ...result } = user; - return result; + /** Отправить код верификации на номер. Лимиты: 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('Некорректный номер телефона'); } - return null; + + 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 }; } - login(user: Omit) { - this.logger.log('LOG'); + /** Проверить код и выдать 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)); } } diff --git a/src/auth/constants.ts b/src/auth/constants.ts index 2241655..5a0f092 100644 --- a/src/auth/constants.ts +++ b/src/auth/constants.ts @@ -1,3 +1,7 @@ export const jwtConstants = { secret: '6by876hiuGHiugiuG8t78t87tGUYUYg8u7g87', + /** Access token: 15 минут (в секундах) */ + accessTokenExpiresIn: 15 * 60, + /** Refresh token: 30 дней (в секундах) */ + refreshTokenExpiresIn: 30 * 24 * 60 * 60, }; diff --git a/src/auth/dto/refresh.dto.ts b/src/auth/dto/refresh.dto.ts new file mode 100644 index 0000000..7de46cb --- /dev/null +++ b/src/auth/dto/refresh.dto.ts @@ -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; +} diff --git a/src/auth/dto/send-code.dto.ts b/src/auth/dto/send-code.dto.ts new file mode 100644 index 0000000..0168f4a --- /dev/null +++ b/src/auth/dto/send-code.dto.ts @@ -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; +} diff --git a/src/auth/dto/verify-code.dto.ts b/src/auth/dto/verify-code.dto.ts new file mode 100644 index 0000000..f3523d8 --- /dev/null +++ b/src/auth/dto/verify-code.dto.ts @@ -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; +} diff --git a/src/auth/local-auth.guard.ts b/src/auth/local-auth.guard.ts deleted file mode 100644 index 45edf56..0000000 --- a/src/auth/local-auth.guard.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { AuthGuard } from '@nestjs/passport'; - -@Injectable() -export class LocalAuthGuard extends AuthGuard('local') {} diff --git a/src/auth/local.strategy.ts b/src/auth/local.strategy.ts deleted file mode 100644 index db98cb5..0000000 --- a/src/auth/local.strategy.ts +++ /dev/null @@ -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> { - const user = await this.authService.validateUser(email, password); - - if (!user) { - throw new UnauthorizedException(); - } - return user; - } -} diff --git a/src/auth/refresh-token-store.service.ts b/src/auth/refresh-token-store.service.ts new file mode 100644 index 0000000..308ed35 --- /dev/null +++ b/src/auth/refresh-token-store.service.ts @@ -0,0 +1,37 @@ +import { Injectable } from '@nestjs/common'; + +interface StoredRefresh { + userId: number; + expiresAt: number; +} + +@Injectable() +export class RefreshTokenStoreService { + private readonly store = new Map(); + private cleanupInterval: ReturnType | 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); + } +} diff --git a/src/auth/sms.service.ts b/src/auth/sms.service.ts new file mode 100644 index 0000000..5501908 --- /dev/null +++ b/src/auth/sms.service.ts @@ -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; +} + +@Injectable() +export class SmsService implements ISmsSender { + private readonly logger = new Logger(SmsService.name); + + async sendVerificationCode(phone: string, code: string): Promise { + // Заглушка: в разработке только логируем. Подключите реальный провайдер SMS. + this.logger.log(`[SMS] Код для ${phone}: ${code}`); + // await this.smsProvider.send(phone, `Ваш код: ${code}`); + } +} diff --git a/src/auth/verification-store.service.ts b/src/auth/verification-store.service.ts new file mode 100644 index 0000000..ed04961 --- /dev/null +++ b/src/auth/verification-store.service.ts @@ -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(); + private readonly lastSentAt = new Map(); + private readonly sentByPhone: { at: number; phone: string }[] = []; + private readonly sentByIp: { at: number; ip: string }[] = []; + private readonly failedAttempts = new Map(); + private cleanupInterval: ReturnType | 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); + } +} diff --git a/src/auth/verification.constants.ts b/src/auth/verification.constants.ts new file mode 100644 index 0000000..701095b --- /dev/null +++ b/src/auth/verification.constants.ts @@ -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; diff --git a/src/users/user.entity.ts b/src/users/user.entity.ts index f98486a..6336452 100644 --- a/src/users/user.entity.ts +++ b/src/users/user.entity.ts @@ -6,7 +6,8 @@ export type User = { lastName: string; phone: string; email: string; - password: string; + /** Не задаётся при регистрации по телефону */ + password?: string; yachts?: Yacht[]; companyName?: string; inn?: number; diff --git a/src/users/users.service.ts b/src/users/users.service.ts index 14f30eb..7439133 100644 --- a/src/users/users.service.ts +++ b/src/users/users.service.ts @@ -66,6 +66,34 @@ export class UsersService { return user; } + async findByPhone( + phone: string, + includeYachts: boolean = false, + ): Promise { + const user = this.users.find((u) => u.phone === phone); + if (user && includeYachts) { + user.yachts = []; + } + return user; + } + + /** Создать пользователя по номеру телефона (при первой авторизации по коду). */ + async createByPhone(phone: string): Promise { + 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,