first
This commit is contained in:
@@ -9,6 +9,7 @@ import {
|
||||
UseGuards,
|
||||
Request,
|
||||
Param,
|
||||
Query,
|
||||
} from '@nestjs/common';
|
||||
import { CommandBus } from '@nestjs/cqrs';
|
||||
import { CqrsModule } from '@nestjs/cqrs';
|
||||
@@ -16,6 +17,7 @@ import { AuthenticateUserCommand } from './commands/authenticate-user.command';
|
||||
import { LoginResponseDto } from './dto/LoginResponseDto';
|
||||
import { LoginDto } from './dto/login.dto';
|
||||
import { ResultModel } from 'src/core/models/result.model';
|
||||
import { DateUtil } from 'src/shared/date.util';
|
||||
import { AuthService } from './auth.service';
|
||||
import { JwtAuthGuard } from '../guards/jwt-auth.guard';
|
||||
import { RateLimitingGuard } from '../guards/rate-limiting.guard';
|
||||
@@ -24,6 +26,13 @@ import { RefreshTokenService } from '../services/refresh-token.service';
|
||||
import { SessionManagementService } from '../services/session-management.service';
|
||||
import { RefreshTokenDto, RefreshTokenResponseDto } from './dto/refresh-token.dto';
|
||||
import { SessionsResponseDto } from './dto/session.dto';
|
||||
import { LoginAuditService } from '../services/login-audit.service';
|
||||
import {
|
||||
LoginAuditFiltersDto,
|
||||
LoginAuditResponseDto,
|
||||
LoginStatsDto,
|
||||
LoginStatsFiltersDto
|
||||
} from './dto/login-audit.dto';
|
||||
import {
|
||||
ApiTags,
|
||||
ApiOperation,
|
||||
@@ -44,6 +53,7 @@ export class AuthController {
|
||||
private readonly rateLimitingService: RateLimitingService,
|
||||
private readonly refreshTokenService: RefreshTokenService,
|
||||
private readonly sessionManagementService: SessionManagementService,
|
||||
private readonly loginAuditService: LoginAuditService,
|
||||
) {}
|
||||
|
||||
@Post('login')
|
||||
@@ -62,30 +72,47 @@ export class AuthController {
|
||||
const command = new AuthenticateUserCommand(dto.username, dto.password);
|
||||
const result = await this.commandBus.execute(command);
|
||||
|
||||
const userAgent = req.headers['user-agent'] || 'Unknown';
|
||||
|
||||
if (!result.success) {
|
||||
// Registra tentativa falhada
|
||||
await this.rateLimitingService.recordAttempt(ip, false);
|
||||
|
||||
await this.loginAuditService.logLoginAttempt({
|
||||
username: dto.username,
|
||||
ipAddress: ip,
|
||||
userAgent,
|
||||
success: false,
|
||||
failureReason: result.error,
|
||||
});
|
||||
|
||||
throw new HttpException(
|
||||
new ResultModel(false, result.error, null, result.error),
|
||||
HttpStatus.UNAUTHORIZED,
|
||||
);
|
||||
}
|
||||
|
||||
// Registra tentativa bem-sucedida (limpa contador)
|
||||
await this.rateLimitingService.recordAttempt(ip, true);
|
||||
|
||||
const user = result.data;
|
||||
const userAgent = req.headers['user-agent'] || 'Unknown';
|
||||
|
||||
// Cria sessão para o usuário primeiro
|
||||
/**
|
||||
* Verifica se o usuário já possui uma sessão ativa
|
||||
*/
|
||||
const existingSession = await this.sessionManagementService.hasActiveSession(user.id);
|
||||
|
||||
if (existingSession) {
|
||||
/**
|
||||
* Encerra a sessão existente antes de criar uma nova
|
||||
*/
|
||||
await this.sessionManagementService.terminateSession(user.id, existingSession.sessionId);
|
||||
}
|
||||
|
||||
const session = await this.sessionManagementService.createSession(
|
||||
user.id,
|
||||
ip,
|
||||
userAgent,
|
||||
);
|
||||
|
||||
// Cria tokens de acesso e refresh com sessionId
|
||||
const tokenPair = await this.authService.createTokenPair(
|
||||
user.id,
|
||||
user.sellerId,
|
||||
@@ -95,11 +122,20 @@ export class AuthController {
|
||||
session.sessionId,
|
||||
);
|
||||
|
||||
await this.loginAuditService.logLoginAttempt({
|
||||
userId: user.id,
|
||||
username: dto.username,
|
||||
ipAddress: ip,
|
||||
userAgent,
|
||||
success: true,
|
||||
sessionId: session.sessionId,
|
||||
});
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
sellerId: user.sellerId,
|
||||
name: user.name,
|
||||
username: user.name,
|
||||
username: dto.username,
|
||||
storeId: user.storeId,
|
||||
email: user.email,
|
||||
accessToken: tokenPair.accessToken,
|
||||
@@ -173,7 +209,7 @@ export class AuthController {
|
||||
@ApiUnauthorizedResponse({ description: 'Token inválido ou expirado' })
|
||||
async getSessions(@Request() req): Promise<SessionsResponseDto> {
|
||||
const userId = req.user.id;
|
||||
const currentSessionId = req.user.sessionId; // ID da sessão atual
|
||||
const currentSessionId = req.user.sessionId;
|
||||
const sessions = await this.sessionManagementService.getActiveSessions(userId, currentSessionId);
|
||||
|
||||
return {
|
||||
@@ -181,8 +217,8 @@ export class AuthController {
|
||||
sessionId: session.sessionId,
|
||||
ipAddress: session.ipAddress,
|
||||
userAgent: session.userAgent,
|
||||
createdAt: new Date(session.createdAt).toISOString(),
|
||||
lastActivity: new Date(session.lastActivity).toISOString(),
|
||||
createdAt: DateUtil.toBrazilISOString(new Date(session.createdAt)),
|
||||
lastActivity: DateUtil.toBrazilISOString(new Date(session.lastActivity)),
|
||||
isCurrent: session.sessionId === currentSessionId,
|
||||
})),
|
||||
total: sessions.length,
|
||||
@@ -222,4 +258,132 @@ export class AuthController {
|
||||
message: 'Todas as sessões foram encerradas com sucesso',
|
||||
};
|
||||
}
|
||||
|
||||
@Get('audit/logs')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ summary: 'Consulta logs de auditoria de login' })
|
||||
@ApiOkResponse({
|
||||
description: 'Lista de logs de auditoria',
|
||||
type: LoginAuditResponseDto,
|
||||
})
|
||||
@ApiUnauthorizedResponse({ description: 'Token inválido ou expirado' })
|
||||
async getLoginAuditLogs(
|
||||
@Query() filters: LoginAuditFiltersDto,
|
||||
@Request() req,
|
||||
): Promise<LoginAuditResponseDto> {
|
||||
const userId = req.user.id;
|
||||
|
||||
const auditFilters = {
|
||||
...filters,
|
||||
userId: filters.userId || userId,
|
||||
startDate: filters.startDate ? new Date(filters.startDate) : undefined,
|
||||
endDate: filters.endDate ? new Date(filters.endDate) : undefined,
|
||||
};
|
||||
|
||||
const logs = await this.loginAuditService.getLoginLogs(auditFilters);
|
||||
|
||||
return {
|
||||
logs: logs.map(log => ({
|
||||
...log,
|
||||
timestamp: DateUtil.toBrazilISOString(log.timestamp),
|
||||
})),
|
||||
total: logs.length,
|
||||
page: Math.floor((filters.offset || 0) / (filters.limit || 100)) + 1,
|
||||
limit: filters.limit || 100,
|
||||
};
|
||||
}
|
||||
|
||||
@Get('audit/stats')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ summary: 'Obtém estatísticas de login' })
|
||||
@ApiOkResponse({
|
||||
description: 'Estatísticas de login',
|
||||
type: LoginStatsDto,
|
||||
})
|
||||
@ApiUnauthorizedResponse({ description: 'Token inválido ou expirado' })
|
||||
async getLoginStats(
|
||||
@Query() filters: LoginStatsFiltersDto,
|
||||
@Request() req,
|
||||
): Promise<LoginStatsDto> {
|
||||
const userId = req.user.id;
|
||||
const days = filters.days || 7;
|
||||
|
||||
const stats = await this.loginAuditService.getLoginStats(
|
||||
filters.userId || userId,
|
||||
days,
|
||||
);
|
||||
|
||||
return stats;
|
||||
}
|
||||
|
||||
@Get('session/status')
|
||||
@ApiOperation({ summary: 'Verifica se o usuário possui uma sessão ativa' })
|
||||
@ApiOkResponse({
|
||||
description: 'Status da sessão do usuário',
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
hasActiveSession: { type: 'boolean' },
|
||||
sessionInfo: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
sessionId: { type: 'string' },
|
||||
ipAddress: { type: 'string' },
|
||||
userAgent: { type: 'string' },
|
||||
createdAt: { type: 'string' },
|
||||
lastActivity: { type: 'string' }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
async checkSessionStatus(@Query('username') username: string): Promise<{
|
||||
hasActiveSession: boolean;
|
||||
sessionInfo?: {
|
||||
sessionId: string;
|
||||
ipAddress: string;
|
||||
userAgent: string;
|
||||
createdAt: string;
|
||||
lastActivity: string;
|
||||
};
|
||||
}> {
|
||||
if (!username) {
|
||||
throw new HttpException(
|
||||
new ResultModel(false, 'Username é obrigatório', null, 'Username é obrigatório'),
|
||||
HttpStatus.BAD_REQUEST,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Busca o usuário pelo username para obter o ID
|
||||
*/
|
||||
const user = await this.authService.findUserByUsername(username);
|
||||
|
||||
if (!user) {
|
||||
return {
|
||||
hasActiveSession: false,
|
||||
};
|
||||
}
|
||||
|
||||
const activeSession = await this.sessionManagementService.hasActiveSession(user.id);
|
||||
|
||||
if (!activeSession) {
|
||||
return {
|
||||
hasActiveSession: false,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
hasActiveSession: true,
|
||||
sessionInfo: {
|
||||
sessionId: activeSession.sessionId,
|
||||
ipAddress: activeSession.ipAddress,
|
||||
userAgent: activeSession.userAgent,
|
||||
createdAt: DateUtil.toBrazilISOString(new Date(activeSession.createdAt)),
|
||||
lastActivity: DateUtil.toBrazilISOString(new Date(activeSession.lastActivity)),
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import { TokenBlacklistService } from '../services/token-blacklist.service';
|
||||
import { RateLimitingService } from '../services/rate-limiting.service';
|
||||
import { RefreshTokenService } from '../services/refresh-token.service';
|
||||
import { SessionManagementService } from '../services/session-management.service';
|
||||
import { LoginAuditService } from '../services/login-audit.service';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -40,6 +41,7 @@ import { SessionManagementService } from '../services/session-management.service
|
||||
RateLimitingService,
|
||||
RefreshTokenService,
|
||||
SessionManagementService,
|
||||
LoginAuditService,
|
||||
AuthenticateUserHandler
|
||||
],
|
||||
exports: [AuthService],
|
||||
|
||||
@@ -37,11 +37,12 @@ export class AuthService {
|
||||
* @param username Nome de usuário
|
||||
* @param email Email do usuário
|
||||
* @param storeId ID da loja
|
||||
* @param sessionId ID da sessão
|
||||
* @returns Objeto com access token e refresh token
|
||||
*/
|
||||
async createTokenPair(id: number, sellerId: number, username: string, email: string, storeId: string, sessionId?: string) {
|
||||
const accessToken = await this.createToken(id, sellerId, username, email, storeId, sessionId);
|
||||
const refreshToken = await this.refreshTokenService.generateRefreshToken(id);
|
||||
const refreshToken = await this.refreshTokenService.generateRefreshToken(id, sessionId);
|
||||
|
||||
return {
|
||||
accessToken,
|
||||
@@ -68,7 +69,8 @@ export class AuthService {
|
||||
user.sellerId,
|
||||
user.name,
|
||||
user.email,
|
||||
user.storeId
|
||||
user.storeId,
|
||||
tokenData.sessionId
|
||||
);
|
||||
|
||||
return {
|
||||
@@ -85,7 +87,7 @@ export class AuthService {
|
||||
id: user.id,
|
||||
sellerId: user.sellerId,
|
||||
storeId: user.storeId,
|
||||
username: user.name, // Usando name como username para consistência
|
||||
username: user.name,
|
||||
email: user.email,
|
||||
};
|
||||
}
|
||||
@@ -106,4 +108,13 @@ export class AuthService {
|
||||
async isTokenBlacklisted(token: string): Promise<boolean> {
|
||||
return this.tokenBlacklistService.isBlacklisted(token);
|
||||
}
|
||||
|
||||
/**
|
||||
* Busca um usuário pelo username
|
||||
* @param username Nome de usuário
|
||||
* @returns Dados do usuário se encontrado
|
||||
*/
|
||||
async findUserByUsername(username: string) {
|
||||
return this.userRepository.findByUsername(username);
|
||||
}
|
||||
}
|
||||
139
src/auth/auth/dto/login-audit.dto.ts
Normal file
139
src/auth/auth/dto/login-audit.dto.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsOptional, IsNumber, IsString, IsBoolean, IsDateString, Min, Max } from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
|
||||
export class LoginAuditFiltersDto {
|
||||
@ApiProperty({ description: 'ID do usuário', required: false })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Type(() => Number)
|
||||
userId?: number;
|
||||
|
||||
@ApiProperty({ description: 'Nome de usuário', required: false })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
username?: string;
|
||||
|
||||
@ApiProperty({ description: 'Endereço IP', required: false })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
ipAddress?: string;
|
||||
|
||||
@ApiProperty({ description: 'Filtrar apenas logins bem-sucedidos', required: false })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
@Type(() => Boolean)
|
||||
success?: boolean;
|
||||
|
||||
@ApiProperty({ description: 'Data de início (ISO string)', required: false })
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
startDate?: string;
|
||||
|
||||
@ApiProperty({ description: 'Data de fim (ISO string)', required: false })
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
endDate?: string;
|
||||
|
||||
@ApiProperty({ description: 'Número de registros por página', required: false, minimum: 1, maximum: 1000 })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Type(() => Number)
|
||||
@Min(1)
|
||||
@Max(1000)
|
||||
limit?: number;
|
||||
|
||||
@ApiProperty({ description: 'Offset para paginação', required: false, minimum: 0 })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Type(() => Number)
|
||||
@Min(0)
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
export class LoginAuditLogDto {
|
||||
@ApiProperty({ description: 'ID único do log' })
|
||||
id: string;
|
||||
|
||||
@ApiProperty({ description: 'ID do usuário', required: false })
|
||||
userId?: number;
|
||||
|
||||
@ApiProperty({ description: 'Nome de usuário' })
|
||||
username: string;
|
||||
|
||||
@ApiProperty({ description: 'Endereço IP' })
|
||||
ipAddress: string;
|
||||
|
||||
@ApiProperty({ description: 'User Agent' })
|
||||
userAgent: string;
|
||||
|
||||
@ApiProperty({ description: 'Login bem-sucedido' })
|
||||
success: boolean;
|
||||
|
||||
@ApiProperty({ description: 'Motivo da falha', required: false })
|
||||
failureReason?: string;
|
||||
|
||||
@ApiProperty({ description: 'Timestamp do login' })
|
||||
timestamp: string;
|
||||
|
||||
@ApiProperty({ description: 'ID da sessão', required: false })
|
||||
sessionId?: string;
|
||||
|
||||
@ApiProperty({ description: 'Localização estimada', required: false })
|
||||
location?: string;
|
||||
}
|
||||
|
||||
export class LoginAuditResponseDto {
|
||||
@ApiProperty({ description: 'Lista de logs de login', type: [LoginAuditLogDto] })
|
||||
logs: LoginAuditLogDto[];
|
||||
|
||||
@ApiProperty({ description: 'Total de registros encontrados' })
|
||||
total: number;
|
||||
|
||||
@ApiProperty({ description: 'Página atual' })
|
||||
page: number;
|
||||
|
||||
@ApiProperty({ description: 'Registros por página' })
|
||||
limit: number;
|
||||
}
|
||||
|
||||
export class LoginStatsDto {
|
||||
@ApiProperty({ description: 'Total de tentativas' })
|
||||
totalAttempts: number;
|
||||
|
||||
@ApiProperty({ description: 'Logins bem-sucedidos' })
|
||||
successfulLogins: number;
|
||||
|
||||
@ApiProperty({ description: 'Logins falhados' })
|
||||
failedLogins: number;
|
||||
|
||||
@ApiProperty({ description: 'Número de IPs únicos' })
|
||||
uniqueIps: number;
|
||||
|
||||
@ApiProperty({ description: 'IPs mais frequentes' })
|
||||
topIps: Array<{ ip: string; count: number }>;
|
||||
|
||||
@ApiProperty({ description: 'Estatísticas diárias' })
|
||||
dailyStats: Array<{
|
||||
date: string;
|
||||
attempts: number;
|
||||
successes: number;
|
||||
failures: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
export class LoginStatsFiltersDto {
|
||||
@ApiProperty({ description: 'ID do usuário para estatísticas', required: false })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Type(() => Number)
|
||||
userId?: number;
|
||||
|
||||
@ApiProperty({ description: 'Número de dias para análise', required: false, minimum: 1, maximum: 365 })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Type(() => Number)
|
||||
@Min(1)
|
||||
@Max(365)
|
||||
days?: number;
|
||||
}
|
||||
295
src/auth/services/login-audit.service.ts
Normal file
295
src/auth/services/login-audit.service.ts
Normal file
@@ -0,0 +1,295 @@
|
||||
import { Injectable, Inject } from '@nestjs/common';
|
||||
import Redis from 'ioredis';
|
||||
import { DateUtil } from 'src/shared/date.util';
|
||||
|
||||
export interface LoginAuditLog {
|
||||
id: string;
|
||||
userId?: number;
|
||||
username: string;
|
||||
ipAddress: string;
|
||||
userAgent: string;
|
||||
success: boolean;
|
||||
failureReason?: string;
|
||||
timestamp: Date;
|
||||
sessionId?: string;
|
||||
location?: string;
|
||||
}
|
||||
|
||||
export interface LoginAuditFilters {
|
||||
userId?: number;
|
||||
username?: string;
|
||||
ipAddress?: string;
|
||||
success?: boolean;
|
||||
startDate?: Date;
|
||||
endDate?: Date;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class LoginAuditService {
|
||||
private readonly LOG_PREFIX = 'login_audit';
|
||||
private readonly LOG_EXPIRY = 30 * 24 * 60 * 60; // 30 dias em segundos
|
||||
|
||||
constructor(
|
||||
@Inject('REDIS_CLIENT') private readonly redis: Redis,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Registra uma tentativa de login
|
||||
* @param log Dados do log de login
|
||||
*/
|
||||
async logLoginAttempt(log: Omit<LoginAuditLog, 'id' | 'timestamp'>): Promise<void> {
|
||||
const logId = this.generateLogId();
|
||||
const timestamp = DateUtil.now();
|
||||
|
||||
const auditLog: LoginAuditLog = {
|
||||
...log,
|
||||
id: logId,
|
||||
timestamp,
|
||||
};
|
||||
|
||||
const logKey = this.buildLogKey(logId);
|
||||
await this.redis.setex(logKey, this.LOG_EXPIRY, JSON.stringify(auditLog));
|
||||
|
||||
if (log.userId) {
|
||||
const userLogsKey = this.buildUserLogsKey(log.userId);
|
||||
await this.redis.lpush(userLogsKey, logId);
|
||||
await this.redis.expire(userLogsKey, this.LOG_EXPIRY);
|
||||
}
|
||||
|
||||
const ipLogsKey = this.buildIpLogsKey(log.ipAddress);
|
||||
await this.redis.lpush(ipLogsKey, logId);
|
||||
await this.redis.expire(ipLogsKey, this.LOG_EXPIRY);
|
||||
|
||||
const globalLogsKey = this.buildGlobalLogsKey();
|
||||
await this.redis.lpush(globalLogsKey, logId);
|
||||
await this.redis.ltrim(globalLogsKey, 0, 999);
|
||||
await this.redis.expire(globalLogsKey, this.LOG_EXPIRY);
|
||||
|
||||
const dateKey = DateUtil.toBrazilString(timestamp, 'yyyy-MM-dd');
|
||||
const dateLogsKey = this.buildDateLogsKey(dateKey);
|
||||
await this.redis.lpush(dateLogsKey, logId);
|
||||
await this.redis.expire(dateLogsKey, this.LOG_EXPIRY);
|
||||
}
|
||||
|
||||
/**
|
||||
* Busca logs de login com filtros
|
||||
* @param filters Filtros para a busca
|
||||
* @returns Lista de logs de login
|
||||
*/
|
||||
async getLoginLogs(filters: LoginAuditFilters = {}): Promise<LoginAuditLog[]> {
|
||||
let logIds: string[] = [];
|
||||
|
||||
if (filters.userId) {
|
||||
const userLogsKey = this.buildUserLogsKey(filters.userId);
|
||||
logIds = await this.redis.lrange(userLogsKey, 0, -1);
|
||||
} else if (filters.ipAddress) {
|
||||
const ipLogsKey = this.buildIpLogsKey(filters.ipAddress);
|
||||
logIds = await this.redis.lrange(ipLogsKey, 0, -1);
|
||||
} else if (filters.startDate || filters.endDate) {
|
||||
const startDate = filters.startDate || new Date(DateUtil.nowTimestamp() - 7 * 24 * 60 * 60 * 1000);
|
||||
const endDate = filters.endDate || DateUtil.now();
|
||||
|
||||
const dates = this.getDateRange(startDate, endDate);
|
||||
for (const date of dates) {
|
||||
const dateLogsKey = this.buildDateLogsKey(date);
|
||||
const dateLogIds = await this.redis.lrange(dateLogsKey, 0, -1);
|
||||
logIds.push(...dateLogIds);
|
||||
}
|
||||
} else {
|
||||
const globalLogsKey = this.buildGlobalLogsKey();
|
||||
logIds = await this.redis.lrange(globalLogsKey, 0, -1);
|
||||
}
|
||||
|
||||
const logs: LoginAuditLog[] = [];
|
||||
for (const logId of logIds) {
|
||||
const logKey = this.buildLogKey(logId);
|
||||
const logData = await this.redis.get(logKey);
|
||||
|
||||
if (logData) {
|
||||
const log: LoginAuditLog = JSON.parse(logData as string);
|
||||
|
||||
/**
|
||||
* Converte timestamp de string para Date se necessário
|
||||
*/
|
||||
if (typeof log.timestamp === 'string') {
|
||||
log.timestamp = new Date(log.timestamp);
|
||||
}
|
||||
|
||||
if (this.matchesFilters(log, filters)) {
|
||||
logs.push(log);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logs.sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime());
|
||||
|
||||
const offset = filters.offset || 0;
|
||||
const limit = filters.limit || 100;
|
||||
|
||||
return logs.slice(offset, offset + limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Busca estatísticas de login
|
||||
* @param userId ID do usuário (opcional)
|
||||
* @param days Número de dias para análise (padrão: 7)
|
||||
* @returns Estatísticas de login
|
||||
*/
|
||||
async getLoginStats(userId?: number, days: number = 7): Promise<{
|
||||
totalAttempts: number;
|
||||
successfulLogins: number;
|
||||
failedLogins: number;
|
||||
uniqueIps: number;
|
||||
topIps: Array<{ ip: string; count: number }>;
|
||||
dailyStats: Array<{ date: string; attempts: number; successes: number; failures: number }>;
|
||||
}> {
|
||||
const endDate = DateUtil.now();
|
||||
const startDate = new Date(endDate.getTime() - days * 24 * 60 * 60 * 1000);
|
||||
|
||||
const filters: LoginAuditFilters = {
|
||||
startDate,
|
||||
endDate,
|
||||
limit: 10000, // Limite alto para estatísticas
|
||||
};
|
||||
|
||||
if (userId) {
|
||||
filters.userId = userId;
|
||||
}
|
||||
|
||||
const logs = await this.getLoginLogs(filters);
|
||||
|
||||
const stats = {
|
||||
totalAttempts: logs.length,
|
||||
successfulLogins: logs.filter(log => log.success).length,
|
||||
failedLogins: logs.filter(log => !log.success).length,
|
||||
uniqueIps: new Set(logs.map(log => log.ipAddress)).size,
|
||||
topIps: [] as Array<{ ip: string; count: number }>,
|
||||
dailyStats: [] as Array<{ date: string; attempts: number; successes: number; failures: number }>,
|
||||
};
|
||||
|
||||
const ipCounts = new Map<string, number>();
|
||||
logs.forEach(log => {
|
||||
ipCounts.set(log.ipAddress, (ipCounts.get(log.ipAddress) || 0) + 1);
|
||||
});
|
||||
|
||||
stats.topIps = Array.from(ipCounts.entries())
|
||||
.map(([ip, count]) => ({ ip, count }))
|
||||
.sort((a, b) => b.count - a.count)
|
||||
.slice(0, 10);
|
||||
|
||||
const dailyCounts = new Map<string, { attempts: number; successes: number; failures: number }>();
|
||||
logs.forEach(log => {
|
||||
const date = DateUtil.toBrazilString(log.timestamp, 'yyyy-MM-dd');
|
||||
const dayStats = dailyCounts.get(date) || { attempts: 0, successes: 0, failures: 0 };
|
||||
dayStats.attempts++;
|
||||
if (log.success) {
|
||||
dayStats.successes++;
|
||||
} else {
|
||||
dayStats.failures++;
|
||||
}
|
||||
dailyCounts.set(date, dayStats);
|
||||
});
|
||||
|
||||
stats.dailyStats = Array.from(dailyCounts.entries())
|
||||
.map(([date, counts]) => ({ date, ...counts }))
|
||||
.sort((a, b) => a.date.localeCompare(b.date));
|
||||
|
||||
return stats;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove logs antigos (mais de 30 dias)
|
||||
*/
|
||||
async cleanupOldLogs(): Promise<void> {
|
||||
const cutoffDate = new Date(DateUtil.nowTimestamp() - 30 * 24 * 60 * 60 * 1000);
|
||||
const cutoffDateStr = DateUtil.toBrazilString(cutoffDate, 'yyyy-MM-dd');
|
||||
|
||||
const oldDates = this.getDateRange(new Date('2020-01-01'), cutoffDate);
|
||||
for (const date of oldDates) {
|
||||
const dateLogsKey = this.buildDateLogsKey(date);
|
||||
await this.redis.del(dateLogsKey);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gera um ID único para o log
|
||||
*/
|
||||
private generateLogId(): string {
|
||||
return `${DateUtil.nowTimestamp()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Constrói a chave para um log específico
|
||||
*/
|
||||
private buildLogKey(logId: string): string {
|
||||
return `${this.LOG_PREFIX}:log:${logId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Constrói a chave para logs de um usuário
|
||||
*/
|
||||
private buildUserLogsKey(userId: number): string {
|
||||
return `${this.LOG_PREFIX}:user:${userId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Constrói a chave para logs de um IP
|
||||
*/
|
||||
private buildIpLogsKey(ipAddress: string): string {
|
||||
return `${this.LOG_PREFIX}:ip:${ipAddress}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Constrói a chave para logs globais
|
||||
*/
|
||||
private buildGlobalLogsKey(): string {
|
||||
return `${this.LOG_PREFIX}:global`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Constrói a chave para logs de uma data específica
|
||||
*/
|
||||
private buildDateLogsKey(date: string): string {
|
||||
return `${this.LOG_PREFIX}:date:${date}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifica se um log corresponde aos filtros
|
||||
*/
|
||||
private matchesFilters(log: LoginAuditLog, filters: LoginAuditFilters): boolean {
|
||||
if (filters.username && !log.username.toLowerCase().includes(filters.username.toLowerCase())) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (filters.success !== undefined && log.success !== filters.success) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (filters.startDate && log.timestamp < filters.startDate) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (filters.endDate && log.timestamp > filters.endDate) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gera array de datas entre startDate e endDate
|
||||
*/
|
||||
private getDateRange(startDate: Date, endDate: Date): string[] {
|
||||
const dates: string[] = [];
|
||||
const currentDate = new Date(startDate);
|
||||
|
||||
while (currentDate <= endDate) {
|
||||
dates.push(DateUtil.toBrazilString(currentDate, 'yyyy-MM-dd'));
|
||||
currentDate.setDate(currentDate.getDate() + 1);
|
||||
}
|
||||
|
||||
return dates;
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,7 @@ export class RateLimitingService {
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Verifica se o IP pode fazer uma tentativa de login
|
||||
* Verifica se o IP pode fazer uma tentativa de login usando operações atômicas
|
||||
* @param ip Endereço IP do cliente
|
||||
* @param config Configuração personalizada (opcional)
|
||||
* @returns true se permitido, false se bloqueado
|
||||
@@ -31,23 +31,51 @@ export class RateLimitingService {
|
||||
const key = this.buildAttemptKey(ip);
|
||||
const blockKey = this.buildBlockKey(ip);
|
||||
|
||||
// Verifica se está bloqueado
|
||||
const isBlocked = await this.redis.get(blockKey);
|
||||
if (isBlocked) {
|
||||
return false;
|
||||
}
|
||||
/**
|
||||
* Usa script Lua para operação atômica (verificação e incremento em uma única operação)
|
||||
*/
|
||||
const luaScript = `
|
||||
local key = KEYS[1]
|
||||
local blockKey = KEYS[2]
|
||||
local maxAttempts = tonumber(ARGV[1])
|
||||
local windowMs = tonumber(ARGV[2])
|
||||
local blockDurationMs = tonumber(ARGV[3])
|
||||
|
||||
-- Verifica se já está bloqueado
|
||||
local isBlocked = redis.call('GET', blockKey)
|
||||
if isBlocked then
|
||||
return {0, 1} -- attempts=0, blocked=1
|
||||
end
|
||||
|
||||
-- Incrementa contador de tentativas
|
||||
local attempts = redis.call('INCR', key)
|
||||
|
||||
-- Se é a primeira tentativa, define TTL
|
||||
if attempts == 1 then
|
||||
redis.call('EXPIRE', key, windowMs / 1000)
|
||||
end
|
||||
|
||||
-- Se excedeu limite, bloqueia
|
||||
if attempts > maxAttempts then
|
||||
redis.call('SET', blockKey, 'blocked', 'EX', blockDurationMs / 1000)
|
||||
return {attempts, 1} -- attempts, blocked=1
|
||||
end
|
||||
|
||||
return {attempts, 0} -- attempts, blocked=0
|
||||
`;
|
||||
|
||||
// Conta tentativas na janela de tempo
|
||||
const attempts = await this.redis.get<string>(key);
|
||||
const attemptCount = attempts ? parseInt(attempts) : 0;
|
||||
const result = await this.redis.eval(
|
||||
luaScript,
|
||||
2,
|
||||
key,
|
||||
blockKey,
|
||||
finalConfig.maxAttempts,
|
||||
finalConfig.windowMs,
|
||||
finalConfig.blockDurationMs
|
||||
) as [number, number];
|
||||
|
||||
if (attemptCount >= finalConfig.maxAttempts) {
|
||||
// Bloqueia o IP
|
||||
await this.redis.set(blockKey, 'blocked', finalConfig.blockDurationMs / 1000);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
const [attempts, isBlockedResult] = result;
|
||||
return isBlockedResult === 0;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -59,15 +87,18 @@ export class RateLimitingService {
|
||||
async recordAttempt(ip: string, success: boolean, config?: Partial<RateLimitConfig>): Promise<void> {
|
||||
const finalConfig = { ...this.defaultConfig, ...config };
|
||||
const key = this.buildAttemptKey(ip);
|
||||
const blockKey = this.buildBlockKey(ip);
|
||||
|
||||
if (success) {
|
||||
/**
|
||||
* Limpa tentativas e bloqueio em caso de sucesso
|
||||
*/
|
||||
await this.redis.del(key);
|
||||
} else {
|
||||
const attempts = await this.redis.get<string>(key);
|
||||
const attemptCount = attempts ? parseInt(attempts) + 1 : 1;
|
||||
|
||||
await this.redis.set(key, attemptCount.toString(), finalConfig.windowMs / 1000);
|
||||
await this.redis.del(blockKey);
|
||||
}
|
||||
/**
|
||||
* Para falhas, o incremento já foi feito no isAllowed() de forma atômica
|
||||
*/
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -4,10 +4,12 @@ import { IRedisClient } from '../../core/configs/cache/IRedisClient';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { JwtPayload } from '../models/jwt-payload.model';
|
||||
import { randomBytes } from 'crypto';
|
||||
import { DateUtil } from 'src/shared/date.util';
|
||||
|
||||
export interface RefreshTokenData {
|
||||
userId: number;
|
||||
tokenId: string;
|
||||
sessionId?: string;
|
||||
expiresAt: number;
|
||||
createdAt: number;
|
||||
}
|
||||
@@ -25,26 +27,30 @@ export class RefreshTokenService {
|
||||
/**
|
||||
* Gera um novo refresh token para o usuário
|
||||
* @param userId ID do usuário
|
||||
* @param sessionId ID da sessão (opcional)
|
||||
* @returns Refresh token
|
||||
*/
|
||||
async generateRefreshToken(userId: number): Promise<string> {
|
||||
async generateRefreshToken(userId: number, sessionId?: string): Promise<string> {
|
||||
const tokenId = randomBytes(32).toString('hex');
|
||||
const refreshToken = this.jwtService.sign(
|
||||
{ userId, tokenId, type: 'refresh' },
|
||||
{ userId, tokenId, sessionId, type: 'refresh' },
|
||||
{ expiresIn: '7d' }
|
||||
);
|
||||
|
||||
const tokenData: RefreshTokenData = {
|
||||
userId,
|
||||
tokenId,
|
||||
expiresAt: Date.now() + (this.REFRESH_TOKEN_TTL * 1000),
|
||||
createdAt: Date.now(),
|
||||
sessionId,
|
||||
expiresAt: DateUtil.nowTimestamp() + (this.REFRESH_TOKEN_TTL * 1000),
|
||||
createdAt: DateUtil.nowTimestamp(),
|
||||
};
|
||||
|
||||
const key = this.buildRefreshTokenKey(userId, tokenId);
|
||||
await this.redis.set(key, tokenData, this.REFRESH_TOKEN_TTL);
|
||||
|
||||
// Limita o número de refresh tokens por usuário
|
||||
/**
|
||||
* Limita o número de refresh tokens por usuário
|
||||
*/
|
||||
await this.limitRefreshTokensPerUser(userId);
|
||||
|
||||
return refreshToken;
|
||||
@@ -63,7 +69,7 @@ export class RefreshTokenService {
|
||||
throw new UnauthorizedException('Token inválido');
|
||||
}
|
||||
|
||||
const { userId, tokenId } = decoded;
|
||||
const { userId, tokenId, sessionId } = decoded;
|
||||
const key = this.buildRefreshTokenKey(userId, tokenId);
|
||||
const tokenData = await this.redis.get<RefreshTokenData>(key);
|
||||
|
||||
@@ -71,7 +77,7 @@ export class RefreshTokenService {
|
||||
throw new UnauthorizedException('Refresh token expirado ou inválido');
|
||||
}
|
||||
|
||||
if (tokenData.expiresAt < Date.now()) {
|
||||
if (tokenData.expiresAt < DateUtil.nowTimestamp()) {
|
||||
await this.revokeRefreshToken(userId, tokenId);
|
||||
throw new UnauthorizedException('Refresh token expirado');
|
||||
}
|
||||
@@ -82,6 +88,7 @@ export class RefreshTokenService {
|
||||
storeId: '',
|
||||
username: '',
|
||||
email: '',
|
||||
sessionId: sessionId || tokenData.sessionId,
|
||||
tokenId
|
||||
} as JwtPayload;
|
||||
} catch (error) {
|
||||
@@ -125,7 +132,7 @@ export class RefreshTokenService {
|
||||
|
||||
for (const key of keys) {
|
||||
const tokenData = await this.redis.get<RefreshTokenData>(key);
|
||||
if (tokenData && tokenData.expiresAt > Date.now()) {
|
||||
if (tokenData && tokenData.expiresAt > DateUtil.nowTimestamp()) {
|
||||
tokens.push(tokenData);
|
||||
}
|
||||
}
|
||||
@@ -141,7 +148,9 @@ export class RefreshTokenService {
|
||||
const activeTokens = await this.getActiveRefreshTokens(userId);
|
||||
|
||||
if (activeTokens.length > this.MAX_REFRESH_TOKENS_PER_USER) {
|
||||
// Remove os tokens mais antigos
|
||||
/**
|
||||
* Remove os tokens mais antigos
|
||||
*/
|
||||
const tokensToRemove = activeTokens
|
||||
.slice(this.MAX_REFRESH_TOKENS_PER_USER)
|
||||
.map(token => token.tokenId);
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Injectable, Inject, NotFoundException } from '@nestjs/common';
|
||||
import { RedisClientToken } from '../../core/configs/cache/redis-client.adapter.provider';
|
||||
import { IRedisClient } from '../../core/configs/cache/IRedisClient';
|
||||
import { randomBytes } from 'crypto';
|
||||
import { DateUtil } from 'src/shared/date.util';
|
||||
|
||||
export interface SessionData {
|
||||
sessionId: string;
|
||||
@@ -16,7 +17,7 @@ export interface SessionData {
|
||||
@Injectable()
|
||||
export class SessionManagementService {
|
||||
private readonly SESSION_TTL = 8 * 60 * 60; // 8 horas em segundos
|
||||
private readonly MAX_SESSIONS_PER_USER = 5; // Máximo 5 sessões por usuário
|
||||
private readonly MAX_SESSIONS_PER_USER = 1; // Máximo 1 sessão por usuário
|
||||
|
||||
constructor(
|
||||
@Inject(RedisClientToken) private readonly redis: IRedisClient,
|
||||
@@ -31,7 +32,7 @@ export class SessionManagementService {
|
||||
*/
|
||||
async createSession(userId: number, ipAddress: string, userAgent: string): Promise<SessionData> {
|
||||
const sessionId = randomBytes(16).toString('hex');
|
||||
const now = Date.now();
|
||||
const now = DateUtil.nowTimestamp();
|
||||
|
||||
const sessionData: SessionData = {
|
||||
sessionId,
|
||||
@@ -62,7 +63,7 @@ export class SessionManagementService {
|
||||
const sessionData = await this.redis.get<SessionData>(key);
|
||||
|
||||
if (sessionData) {
|
||||
sessionData.lastActivity = Date.now();
|
||||
sessionData.lastActivity = DateUtil.nowTimestamp();
|
||||
await this.redis.set(key, sessionData, this.SESSION_TTL);
|
||||
}
|
||||
}
|
||||
@@ -158,6 +159,16 @@ export class SessionManagementService {
|
||||
return sessionData ? sessionData.isActive : false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifica se o usuário possui uma sessão ativa
|
||||
* @param userId ID do usuário
|
||||
* @returns Dados da sessão ativa se existir, null caso contrário
|
||||
*/
|
||||
async hasActiveSession(userId: number): Promise<SessionData | null> {
|
||||
const activeSessions = await this.getActiveSessions(userId);
|
||||
return activeSessions.length > 0 ? activeSessions[0] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Limita o número de sessões por usuário
|
||||
* @param userId ID do usuário
|
||||
|
||||
@@ -31,17 +31,34 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||
throw new UnauthorizedException('Token foi invalidado');
|
||||
}
|
||||
|
||||
const sessionKey = this.buildSessionKey(payload.id);
|
||||
/**
|
||||
* Usa a mesma chave que o SessionManagementService
|
||||
* Formato: auth:sessions:userId:sessionId
|
||||
*/
|
||||
const sessionKey = this.buildSessionKey(payload.id, payload.sessionId);
|
||||
const cachedUser = await this.redis.get<any>(sessionKey);
|
||||
|
||||
if (cachedUser) {
|
||||
/**
|
||||
* Verifica se a sessão ainda está ativa
|
||||
*/
|
||||
const isSessionActive = await this.sessionManagementService.isSessionActive(
|
||||
payload.id,
|
||||
payload.sessionId
|
||||
);
|
||||
|
||||
if (!isSessionActive) {
|
||||
throw new UnauthorizedException('Sessão expirada ou inválida');
|
||||
}
|
||||
|
||||
return {
|
||||
id: cachedUser.id,
|
||||
sellerId: cachedUser.sellerId,
|
||||
storeId: cachedUser.storeId,
|
||||
username: cachedUser.name,
|
||||
username: cachedUser.username, // ← Corrigido: usar username em vez de name
|
||||
email: cachedUser.email,
|
||||
name: cachedUser.name,
|
||||
sessionId: payload.sessionId,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -50,14 +67,21 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||
throw new UnauthorizedException('Usuário inválido ou inativo');
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifica se usuário está bloqueado (consistência com AuthenticateUserHandler)
|
||||
*/
|
||||
if (user.situacao === 'B') {
|
||||
throw new UnauthorizedException('Usuário bloqueado, acesso não permitido');
|
||||
}
|
||||
|
||||
const userData = {
|
||||
id: user.id,
|
||||
sellerId: user.sellerId,
|
||||
storeId: user.storeId,
|
||||
username: user.name,
|
||||
username: user.name, // ← Manter name como username para compatibilidade
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
sessionId: payload.sessionId, // Inclui sessionId do token
|
||||
sessionId: payload.sessionId,
|
||||
};
|
||||
|
||||
await this.redis.set(sessionKey, userData, 60 * 60 * 8);
|
||||
@@ -65,7 +89,13 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||
return userData;
|
||||
}
|
||||
|
||||
private buildSessionKey(userId: number): string {
|
||||
return `auth:sessions:${userId}`;
|
||||
/**
|
||||
* Constrói a chave de sessão no mesmo formato do SessionManagementService
|
||||
* @param userId ID do usuário
|
||||
* @param sessionId ID da sessão
|
||||
* @returns Chave para o Redis
|
||||
*/
|
||||
private buildSessionKey(userId: number, sessionId: string): string {
|
||||
return `auth:sessions:${userId}:${sessionId}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,4 +73,16 @@ export class UserRepository {
|
||||
const result = await this.dataSource.query(sql, [id]);
|
||||
return result[0] || null;
|
||||
}
|
||||
|
||||
async findByUsername(username: string) {
|
||||
const sql = `
|
||||
SELECT MATRICULA AS "id", NOME AS "name", CODUSUR AS "sellerId",
|
||||
CODFILIAL AS "storeId", EMAIL AS "email",
|
||||
DTDEMISSAO as "dataDesligamento", SITUACAO as "situacao"
|
||||
FROM PCEMPR
|
||||
WHERE USUARIOBD = :1
|
||||
`;
|
||||
const result = await this.dataSource.query(sql, [username.toUpperCase()]);
|
||||
return result[0] || null;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user