This commit is contained in:
Joelson
2025-09-17 18:49:23 -03:00
parent 21c3225c52
commit e081df9ced
42 changed files with 4129 additions and 411 deletions

View File

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

View File

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

View File

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

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

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

View File

@@ -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
*/
}
/**

View File

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

View File

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

View File

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

View File

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