авторизация по телефону #1

Merged
iaiaiaia merged 1 commits from feature/phone-auth into main 2026-03-07 10:29:21 +00:00
16 changed files with 469 additions and 81 deletions

View File

@ -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 {}

View File

@ -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);
}
}

View File

@ -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 {}

View File

@ -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));
}
}

View File

@ -1,3 +1,7 @@
export const jwtConstants = {
secret: '6by876hiuGHiugiuG8t78t87tGUYUYg8u7g87',
/** Access token: 15 минут (в секундах) */
accessTokenExpiresIn: 15 * 60,
/** Refresh token: 30 дней (в секундах) */
refreshTokenExpiresIn: 30 * 24 * 60 * 60,
};

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -1,5 +0,0 @@
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
@Injectable()
export class LocalAuthGuard extends AuthGuard('local') {}

View File

@ -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;
}
}

View File

@ -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);
}
}

19
src/auth/sms.service.ts Normal file
View File

@ -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}`);
}
}

View File

@ -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);
}
}

View File

@ -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;

View File

@ -6,7 +6,8 @@ export type User = {
lastName: string;
phone: string;
email: string;
password: string;
/** Не задаётся при регистрации по телефону */
password?: string;
yachts?: Yacht[];
companyName?: string;
inn?: number;

View File

@ -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,