refactor: atualizações e remoção de módulos não utilizados

This commit is contained in:
joelson brito
2025-11-10 09:39:44 -03:00
parent ed68b7e865
commit b8630adf92
121 changed files with 3507 additions and 3531 deletions

View File

@@ -46,7 +46,9 @@ export interface AuthServiceTestContext {
mockUserRepository: ReturnType<typeof createMockUserRepository>;
mockTokenBlacklistService: ReturnType<typeof createMockTokenBlacklistService>;
mockRefreshTokenService: ReturnType<typeof createMockRefreshTokenService>;
mockSessionManagementService: ReturnType<typeof createMockSessionManagementService>;
mockSessionManagementService: ReturnType<
typeof createMockSessionManagementService
>;
}
export async function createAuthServiceTestModule(): Promise<AuthServiceTestContext> {
@@ -101,4 +103,3 @@ export async function createAuthServiceTestModule(): Promise<AuthServiceTestCont
mockSessionManagementService,
};
}

View File

@@ -29,7 +29,7 @@ describe('AuthService - createToken', () => {
username,
email,
storeId,
sessionId
sessionId,
);
expect(context.mockJwtService.sign).toHaveBeenCalledWith(
@@ -41,7 +41,7 @@ describe('AuthService - createToken', () => {
email: email,
sessionId: sessionId,
},
{ expiresIn: '8h' }
{ expiresIn: '8h' },
);
expect(result).toBe(mockToken);
});
@@ -61,7 +61,7 @@ describe('AuthService - createToken', () => {
sellerId,
username,
email,
storeId
storeId,
);
expect(context.mockJwtService.sign).toHaveBeenCalledWith(
@@ -73,7 +73,7 @@ describe('AuthService - createToken', () => {
email: email,
sessionId: undefined,
},
{ expiresIn: '8h' }
{ expiresIn: '8h' },
);
expect(result).toBe(mockToken);
});
@@ -93,12 +93,12 @@ describe('AuthService - createToken', () => {
sellerId,
username,
email,
storeId
storeId,
);
expect(context.mockJwtService.sign).toHaveBeenCalledWith(
expect.any(Object),
{ expiresIn: '8h' }
{ expiresIn: '8h' },
);
});
@@ -119,7 +119,7 @@ describe('AuthService - createToken', () => {
username,
email,
storeId,
sessionId
sessionId,
);
const signCall = context.mockJwtService.sign.mock.calls[0];
@@ -150,7 +150,7 @@ describe('AuthService - createToken', () => {
username,
email,
storeId,
sessionId
sessionId,
);
expect(context.mockJwtService.sign).toHaveBeenCalledWith(
@@ -162,7 +162,7 @@ describe('AuthService - createToken', () => {
email: email,
sessionId: sessionId,
},
{ expiresIn: '8h' }
{ expiresIn: '8h' },
);
expect(result).toBe(mockToken);
});
@@ -171,7 +171,13 @@ describe('AuthService - createToken', () => {
const mockToken = 'mock.jwt.token.once';
context.mockJwtService.sign.mockReturnValue(mockToken);
await context.service.createToken(1, 100, 'test', 'test@test.com', 'STORE001');
await context.service.createToken(
1,
100,
'test',
'test@test.com',
'STORE001',
);
expect(context.mockJwtService.sign).toHaveBeenCalledTimes(1);
});
@@ -183,7 +189,7 @@ describe('AuthService - createToken', () => {
* de validação no método createToken. Atualmente, o método não valida
* os parâmetros de entrada, o que pode causar problemas de segurança
* e tokens inválidos.
*
*
* PROBLEMAS IDENTIFICADOS:
* 1. Não valida se IDs são positivos
* 2. Não valida se strings estão vazias
@@ -199,7 +205,13 @@ describe('AuthService - createToken', () => {
const negativeId = -1;
await expect(
context.service.createToken(negativeId, 100, 'test', 'test@test.com', 'STORE001')
context.service.createToken(
negativeId,
100,
'test',
'test@test.com',
'STORE001',
),
).rejects.toThrow('ID de usuário inválido');
});
@@ -207,7 +219,13 @@ describe('AuthService - createToken', () => {
const zeroId = 0;
await expect(
context.service.createToken(zeroId, 100, 'test', 'test@test.com', 'STORE001')
context.service.createToken(
zeroId,
100,
'test',
'test@test.com',
'STORE001',
),
).rejects.toThrow('ID de usuário inválido');
});
@@ -215,7 +233,13 @@ describe('AuthService - createToken', () => {
const negativeSellerId = -1;
await expect(
context.service.createToken(1, negativeSellerId, 'test', 'test@test.com', 'STORE001')
context.service.createToken(
1,
negativeSellerId,
'test',
'test@test.com',
'STORE001',
),
).rejects.toThrow('ID de vendedor inválido');
});
@@ -223,7 +247,13 @@ describe('AuthService - createToken', () => {
const emptyUsername = '';
await expect(
context.service.createToken(1, 100, emptyUsername, 'test@test.com', 'STORE001')
context.service.createToken(
1,
100,
emptyUsername,
'test@test.com',
'STORE001',
),
).rejects.toThrow('Nome de usuário não pode estar vazio');
});
@@ -231,7 +261,13 @@ describe('AuthService - createToken', () => {
const whitespaceUsername = ' ';
await expect(
context.service.createToken(1, 100, whitespaceUsername, 'test@test.com', 'STORE001')
context.service.createToken(
1,
100,
whitespaceUsername,
'test@test.com',
'STORE001',
),
).rejects.toThrow('Nome de usuário não pode estar vazio');
});
@@ -239,7 +275,7 @@ describe('AuthService - createToken', () => {
const emptyEmail = '';
await expect(
context.service.createToken(1, 100, 'test', emptyEmail, 'STORE001')
context.service.createToken(1, 100, 'test', emptyEmail, 'STORE001'),
).rejects.toThrow('Email não pode estar vazio');
});
@@ -247,7 +283,7 @@ describe('AuthService - createToken', () => {
const invalidEmail = 'not-an-email';
await expect(
context.service.createToken(1, 100, 'test', invalidEmail, 'STORE001')
context.service.createToken(1, 100, 'test', invalidEmail, 'STORE001'),
).rejects.toThrow('Formato de email inválido');
});
@@ -255,7 +291,7 @@ describe('AuthService - createToken', () => {
const invalidEmail = 'testemail.com';
await expect(
context.service.createToken(1, 100, 'test', invalidEmail, 'STORE001')
context.service.createToken(1, 100, 'test', invalidEmail, 'STORE001'),
).rejects.toThrow('Formato de email inválido');
});
@@ -263,19 +299,37 @@ describe('AuthService - createToken', () => {
const emptyStoreId = '';
await expect(
context.service.createToken(1, 100, 'test', 'test@test.com', emptyStoreId)
context.service.createToken(
1,
100,
'test',
'test@test.com',
emptyStoreId,
),
).rejects.toThrow('ID da loja não pode estar vazio');
});
it('should reject null username', async () => {
await expect(
context.service.createToken(1, 100, null as any, 'test@test.com', 'STORE001')
context.service.createToken(
1,
100,
null as any,
'test@test.com',
'STORE001',
),
).rejects.toThrow('Nome de usuário não pode estar vazio');
});
it('should reject undefined email', async () => {
await expect(
context.service.createToken(1, 100, 'test', undefined as any, 'STORE001')
context.service.createToken(
1,
100,
'test',
undefined as any,
'STORE001',
),
).rejects.toThrow('Email não pode estar vazio');
});
@@ -283,7 +337,13 @@ describe('AuthService - createToken', () => {
const specialCharsOnly = '@#$%';
await expect(
context.service.createToken(1, 100, specialCharsOnly, 'test@test.com', 'STORE001')
context.service.createToken(
1,
100,
specialCharsOnly,
'test@test.com',
'STORE001',
),
).rejects.toThrow('Nome de usuário inválido');
});
@@ -291,7 +351,13 @@ describe('AuthService - createToken', () => {
const longUsername = 'a'.repeat(10000);
await expect(
context.service.createToken(1, 100, longUsername, 'test@test.com', 'STORE001')
context.service.createToken(
1,
100,
longUsername,
'test@test.com',
'STORE001',
),
).rejects.toThrow('Nome de usuário muito longo');
});
@@ -299,7 +365,7 @@ describe('AuthService - createToken', () => {
const longEmail = 'a'.repeat(10000) + '@test.com';
await expect(
context.service.createToken(1, 100, 'test', longEmail, 'STORE001')
context.service.createToken(1, 100, 'test', longEmail, 'STORE001'),
).rejects.toThrow('Email muito longo');
});
@@ -307,7 +373,13 @@ describe('AuthService - createToken', () => {
const sqlInjection = "admin'; DROP TABLE users; --";
await expect(
context.service.createToken(1, 100, sqlInjection, 'test@test.com', 'STORE001')
context.service.createToken(
1,
100,
sqlInjection,
'test@test.com',
'STORE001',
),
).rejects.toThrow('Nome de usuário contém caracteres inválidos');
});
@@ -315,9 +387,8 @@ describe('AuthService - createToken', () => {
const invalidEmail = 'test@@example.com';
await expect(
context.service.createToken(1, 100, 'test', invalidEmail, 'STORE001')
context.service.createToken(1, 100, 'test', invalidEmail, 'STORE001'),
).rejects.toThrow('Formato de email inválido');
});
});
});

View File

@@ -14,7 +14,7 @@ describe('AuthService - createTokenPair', () => {
describe('createTokenPair - Tests that expose problems', () => {
/**
* NOTA: Estes testes identificam problemas no método createTokenPair.
*
*
* PROBLEMAS IDENTIFICADOS:
* 1. Não há rollback se um token é criado mas o outro falha
* 2. Não valida se os tokens foram realmente gerados
@@ -25,7 +25,9 @@ describe('AuthService - createTokenPair', () => {
beforeEach(() => {
context.mockJwtService.sign.mockReturnValue('mock.access.token');
context.mockRefreshTokenService.generateRefreshToken.mockResolvedValue('mock.refresh.token');
context.mockRefreshTokenService.generateRefreshToken.mockResolvedValue(
'mock.refresh.token',
);
});
it('should handle error when createToken fails after refresh token is generated', async () => {
@@ -39,10 +41,19 @@ describe('AuthService - createTokenPair', () => {
});
await expect(
context.service.createTokenPair(1, 100, 'test', 'test@test.com', 'STORE001', 'session-123')
context.service.createTokenPair(
1,
100,
'test',
'test@test.com',
'STORE001',
'session-123',
),
).rejects.toThrow();
expect(context.mockRefreshTokenService.generateRefreshToken).not.toHaveBeenCalled();
expect(
context.mockRefreshTokenService.generateRefreshToken,
).not.toHaveBeenCalled();
});
it('should rollback access token if refresh token generation fails', async () => {
@@ -52,11 +63,18 @@ describe('AuthService - createTokenPair', () => {
* Solução esperada: Invalidar o access token ou garantir atomicidade.
*/
context.mockRefreshTokenService.generateRefreshToken.mockRejectedValueOnce(
new Error('Falha ao gerar refresh token')
new Error('Falha ao gerar refresh token'),
);
await expect(
context.service.createTokenPair(1, 100, 'test', 'test@test.com', 'STORE001', 'session-123')
context.service.createTokenPair(
1,
100,
'test',
'test@test.com',
'STORE001',
'session-123',
),
).rejects.toThrow('Falha ao gerar refresh token');
});
@@ -69,7 +87,13 @@ describe('AuthService - createTokenPair', () => {
context.mockJwtService.sign.mockReturnValue('');
await expect(
context.service.createTokenPair(1, 100, 'test', 'test@test.com', 'STORE001')
context.service.createTokenPair(
1,
100,
'test',
'test@test.com',
'STORE001',
),
).rejects.toThrow('Token de acesso inválido gerado');
});
@@ -79,18 +103,34 @@ describe('AuthService - createTokenPair', () => {
* Problema: Método não valida o retorno.
* Solução esperada: Lançar exceção se token for inválido.
*/
context.mockRefreshTokenService.generateRefreshToken.mockResolvedValue('');
context.mockRefreshTokenService.generateRefreshToken.mockResolvedValue(
'',
);
await expect(
context.service.createTokenPair(1, 100, 'test', 'test@test.com', 'STORE001')
context.service.createTokenPair(
1,
100,
'test',
'test@test.com',
'STORE001',
),
).rejects.toThrow('Refresh token inválido gerado');
});
it('should validate that refresh token is not null', async () => {
context.mockRefreshTokenService.generateRefreshToken.mockResolvedValue(null);
context.mockRefreshTokenService.generateRefreshToken.mockResolvedValue(
null,
);
await expect(
context.service.createTokenPair(1, 100, 'test', 'test@test.com', 'STORE001')
context.service.createTokenPair(
1,
100,
'test',
'test@test.com',
'STORE001',
),
).rejects.toThrow('Refresh token inválido gerado');
});
@@ -101,18 +141,26 @@ describe('AuthService - createTokenPair', () => {
* Solução esperada: Access token sempre primeiro.
*/
const callOrder = [];
context.mockJwtService.sign.mockImplementation(() => {
callOrder.push('accessToken');
return 'mock.access.token';
});
context.mockRefreshTokenService.generateRefreshToken.mockImplementation(async () => {
callOrder.push('refreshToken');
return 'mock.refresh.token';
});
await context.service.createTokenPair(1, 100, 'test', 'test@test.com', 'STORE001');
context.mockRefreshTokenService.generateRefreshToken.mockImplementation(
async () => {
callOrder.push('refreshToken');
return 'mock.refresh.token';
},
);
await context.service.createTokenPair(
1,
100,
'test',
'test@test.com',
'STORE001',
);
expect(callOrder).toEqual(['accessToken', 'refreshToken']);
});
@@ -123,7 +171,13 @@ describe('AuthService - createTokenPair', () => {
* Problema: Cliente pode não saber quando renovar o token.
* Solução esperada: Sempre retornar um número positivo válido.
*/
const result = await context.service.createTokenPair(1, 100, 'test', 'test@test.com', 'STORE001');
const result = await context.service.createTokenPair(
1,
100,
'test',
'test@test.com',
'STORE001',
);
expect(result.expiresIn).toBeGreaterThan(0);
expect(typeof result.expiresIn).toBe('number');
@@ -135,7 +189,7 @@ describe('AuthService - createTokenPair', () => {
* Cenário: Múltiplas chamadas simultâneas para o mesmo usuário.
* Problema: Pode criar múltiplos pares de tokens inconsistentes.
* Solução esperada: JWT service gera tokens únicos com timestamps diferentes.
*
*
* Nota: Na implementação real, o JWT service inclui timestamp e outros dados
* que garantem unicidade. Aqui simulamos isso no mock.
*/
@@ -145,19 +199,42 @@ describe('AuthService - createTokenPair', () => {
return `mock.access.token.${callCount}`;
});
context.mockRefreshTokenService.generateRefreshToken.mockImplementation(async () => {
return `mock.refresh.token.${Math.random()}`;
});
context.mockRefreshTokenService.generateRefreshToken.mockImplementation(
async () => {
return `mock.refresh.token.${Math.random()}`;
},
);
const promises = [
context.service.createTokenPair(1, 100, 'test', 'test@test.com', 'STORE001', 'session-1'),
context.service.createTokenPair(1, 100, 'test', 'test@test.com', 'STORE001', 'session-2'),
context.service.createTokenPair(1, 100, 'test', 'test@test.com', 'STORE001', 'session-3'),
context.service.createTokenPair(
1,
100,
'test',
'test@test.com',
'STORE001',
'session-1',
),
context.service.createTokenPair(
1,
100,
'test',
'test@test.com',
'STORE001',
'session-2',
),
context.service.createTokenPair(
1,
100,
'test',
'test@test.com',
'STORE001',
'session-3',
),
];
const results = await Promise.all(promises);
const uniqueTokens = new Set(results.map(r => r.accessToken));
const uniqueTokens = new Set(results.map((r) => r.accessToken));
expect(uniqueTokens.size).toBe(3);
});
@@ -168,10 +245,18 @@ describe('AuthService - createTokenPair', () => {
* Solução esperada: Falhar rápido com mensagem clara.
*/
await expect(
context.service.createTokenPair(-1, 100, 'test', 'test@test.com', 'STORE001')
context.service.createTokenPair(
-1,
100,
'test',
'test@test.com',
'STORE001',
),
).rejects.toThrow('ID de usuário inválido');
expect(context.mockRefreshTokenService.generateRefreshToken).not.toHaveBeenCalled();
expect(
context.mockRefreshTokenService.generateRefreshToken,
).not.toHaveBeenCalled();
});
it('should not create refresh token if validation fails', async () => {
@@ -181,11 +266,19 @@ describe('AuthService - createTokenPair', () => {
* Solução esperada: Validar tudo antes de criar qualquer token.
*/
await expect(
context.service.createTokenPair(1, -1, 'test', 'test@test.com', 'STORE001')
context.service.createTokenPair(
1,
-1,
'test',
'test@test.com',
'STORE001',
),
).rejects.toThrow('ID de vendedor inválido');
expect(context.mockJwtService.sign).not.toHaveBeenCalled();
expect(context.mockRefreshTokenService.generateRefreshToken).not.toHaveBeenCalled();
expect(
context.mockRefreshTokenService.generateRefreshToken,
).not.toHaveBeenCalled();
});
it('should handle undefined sessionId gracefully', async () => {
@@ -194,11 +287,19 @@ describe('AuthService - createTokenPair', () => {
* Problema: Pode causar problemas ao gerar tokens sem session.
* Solução esperada: Aceitar undefined e passar corretamente aos serviços.
*/
const result = await context.service.createTokenPair(1, 100, 'test', 'test@test.com', 'STORE001');
const result = await context.service.createTokenPair(
1,
100,
'test',
'test@test.com',
'STORE001',
);
expect(result.accessToken).toBeDefined();
expect(result.refreshToken).toBeDefined();
expect(context.mockRefreshTokenService.generateRefreshToken).toHaveBeenCalledWith(1, undefined);
expect(
context.mockRefreshTokenService.generateRefreshToken,
).toHaveBeenCalledWith(1, undefined);
});
it('should include all required fields in return object', async () => {
@@ -207,7 +308,13 @@ describe('AuthService - createTokenPair', () => {
* Problema: Pode faltar campos ou ter campos extras.
* Solução esperada: Sempre retornar accessToken, refreshToken e expiresIn.
*/
const result = await context.service.createTokenPair(1, 100, 'test', 'test@test.com', 'STORE001');
const result = await context.service.createTokenPair(
1,
100,
'test',
'test@test.com',
'STORE001',
);
expect(result).toHaveProperty('accessToken');
expect(result).toHaveProperty('refreshToken');
@@ -216,4 +323,3 @@ describe('AuthService - createTokenPair', () => {
});
});
});

View File

@@ -13,8 +13,12 @@ describe('AuthService - logout', () => {
storeId: 'STORE001',
sessionId: 'session-123',
});
context.mockTokenBlacklistService.addToBlacklist.mockResolvedValue(undefined);
context.mockSessionManagementService.terminateSession.mockResolvedValue(undefined);
context.mockTokenBlacklistService.addToBlacklist.mockResolvedValue(
undefined,
);
context.mockSessionManagementService.terminateSession.mockResolvedValue(
undefined,
);
});
afterEach(() => {
@@ -24,7 +28,7 @@ describe('AuthService - logout', () => {
describe('logout - Tests that expose problems', () => {
/**
* NOTA: Estes testes identificam problemas no método logout.
*
*
* PROBLEMAS IDENTIFICADOS:
* 1. Não valida token de entrada (vazio, null, undefined)
* 2. Não valida se token foi decodificado corretamente
@@ -37,66 +41,76 @@ describe('AuthService - logout', () => {
*/
it('should reject empty token', async () => {
await expect(
context.service.logout('')
).rejects.toThrow('Token não pode estar vazio');
await expect(context.service.logout('')).rejects.toThrow(
'Token não pode estar vazio',
);
expect(context.mockJwtService.decode).not.toHaveBeenCalled();
expect(context.mockTokenBlacklistService.addToBlacklist).not.toHaveBeenCalled();
expect(
context.mockTokenBlacklistService.addToBlacklist,
).not.toHaveBeenCalled();
});
it('should reject null token', async () => {
await expect(
context.service.logout(null as any)
).rejects.toThrow('Token não pode estar vazio');
await expect(context.service.logout(null as any)).rejects.toThrow(
'Token não pode estar vazio',
);
expect(context.mockJwtService.decode).not.toHaveBeenCalled();
expect(context.mockTokenBlacklistService.addToBlacklist).not.toHaveBeenCalled();
expect(
context.mockTokenBlacklistService.addToBlacklist,
).not.toHaveBeenCalled();
});
it('should reject undefined token', async () => {
await expect(
context.service.logout(undefined as any)
).rejects.toThrow('Token não pode estar vazio');
await expect(context.service.logout(undefined as any)).rejects.toThrow(
'Token não pode estar vazio',
);
expect(context.mockJwtService.decode).not.toHaveBeenCalled();
expect(context.mockTokenBlacklistService.addToBlacklist).not.toHaveBeenCalled();
expect(
context.mockTokenBlacklistService.addToBlacklist,
).not.toHaveBeenCalled();
});
it('should reject whitespace-only token', async () => {
await expect(
context.service.logout(' ')
).rejects.toThrow('Token não pode estar vazio');
await expect(context.service.logout(' ')).rejects.toThrow(
'Token não pode estar vazio',
);
expect(context.mockJwtService.decode).not.toHaveBeenCalled();
expect(context.mockTokenBlacklistService.addToBlacklist).not.toHaveBeenCalled();
expect(
context.mockTokenBlacklistService.addToBlacklist,
).not.toHaveBeenCalled();
});
it('should reject extremely long tokens (DoS prevention)', async () => {
const hugeToken = 'a'.repeat(100000);
await expect(
context.service.logout(hugeToken)
).rejects.toThrow('Token muito longo');
await expect(context.service.logout(hugeToken)).rejects.toThrow(
'Token muito longo',
);
expect(context.mockJwtService.decode).not.toHaveBeenCalled();
expect(context.mockTokenBlacklistService.addToBlacklist).not.toHaveBeenCalled();
expect(
context.mockTokenBlacklistService.addToBlacklist,
).not.toHaveBeenCalled();
});
it('should validate decoded token is not null', async () => {
context.mockJwtService.decode.mockReturnValue(null);
await expect(
context.service.logout('invalid.token')
).rejects.toThrow('Token inválido ou não pode ser decodificado');
await expect(context.service.logout('invalid.token')).rejects.toThrow(
'Token inválido ou não pode ser decodificado',
);
});
it('should validate decoded token has required fields', async () => {
context.mockJwtService.decode.mockReturnValue({} as any);
await expect(
context.service.logout('incomplete.token')
).rejects.toThrow('Token inválido ou não pode ser decodificado');
await expect(context.service.logout('incomplete.token')).rejects.toThrow(
'Token inválido ou não pode ser decodificado',
);
});
it('should not add token to blacklist if already blacklisted', async () => {
@@ -104,7 +118,9 @@ describe('AuthService - logout', () => {
await context.service.logout('already.blacklisted.token');
expect(context.mockTokenBlacklistService.addToBlacklist).not.toHaveBeenCalled();
expect(
context.mockTokenBlacklistService.addToBlacklist,
).not.toHaveBeenCalled();
});
it('should validate session exists before terminating', async () => {
@@ -114,11 +130,11 @@ describe('AuthService - logout', () => {
} as any);
context.mockSessionManagementService.terminateSession.mockRejectedValue(
new Error('Sessão não encontrada')
new Error('Sessão não encontrada'),
);
await expect(
context.service.logout('token.with.invalid.session')
context.service.logout('token.with.invalid.session'),
).rejects.toThrow('Sessão não encontrada');
});
@@ -128,16 +144,16 @@ describe('AuthService - logout', () => {
});
await expect(
context.service.logout('invalid.token.format')
context.service.logout('invalid.token.format'),
).rejects.toThrow('Token inválido ou não pode ser decodificado');
});
it('should sanitize token input', async () => {
const maliciousToken = "'; DROP TABLE users; --";
await expect(
context.service.logout(maliciousToken)
).rejects.toThrow('Formato de token inválido');
await expect(context.service.logout(maliciousToken)).rejects.toThrow(
'Formato de token inválido',
);
expect(context.mockJwtService.decode).not.toHaveBeenCalled();
});
@@ -149,7 +165,7 @@ describe('AuthService - logout', () => {
} as any);
await expect(
context.service.logout('token.with.invalid.id')
context.service.logout('token.with.invalid.id'),
).rejects.toThrow('ID de usuário inválido no token');
});
@@ -161,7 +177,9 @@ describe('AuthService - logout', () => {
await context.service.logout('token.with.empty.sessionid');
expect(context.mockSessionManagementService.terminateSession).not.toHaveBeenCalled();
expect(
context.mockSessionManagementService.terminateSession,
).not.toHaveBeenCalled();
});
it('should complete logout even if session termination fails', async () => {
@@ -172,23 +190,27 @@ describe('AuthService - logout', () => {
context.mockTokenBlacklistService.isBlacklisted.mockResolvedValue(false);
context.mockSessionManagementService.terminateSession.mockRejectedValue(
new Error('Falha ao terminar sessão')
new Error('Falha ao terminar sessão'),
);
await context.service.logout('valid.token');
expect(context.mockTokenBlacklistService.addToBlacklist).toHaveBeenCalledWith('valid.token');
expect(
context.mockTokenBlacklistService.addToBlacklist,
).toHaveBeenCalledWith('valid.token');
});
it('should not throw if token is already blacklisted', async () => {
context.mockTokenBlacklistService.isBlacklisted.mockResolvedValue(true);
context.mockTokenBlacklistService.addToBlacklist.mockRejectedValue(
new Error('Token já está na blacklist')
new Error('Token já está na blacklist'),
);
await context.service.logout('already.blacklisted.token');
expect(context.mockTokenBlacklistService.addToBlacklist).not.toHaveBeenCalled();
expect(
context.mockTokenBlacklistService.addToBlacklist,
).not.toHaveBeenCalled();
});
it('should validate token format before decoding', async () => {
@@ -214,7 +236,9 @@ describe('AuthService - logout', () => {
await Promise.all(promises);
expect(context.mockTokenBlacklistService.addToBlacklist).toHaveBeenCalledTimes(3);
expect(
context.mockTokenBlacklistService.addToBlacklist,
).toHaveBeenCalledTimes(3);
});
it('should validate decoded payload structure', async () => {
@@ -223,11 +247,15 @@ describe('AuthService - logout', () => {
} as any);
await expect(
context.service.logout('token.with.invalid.structure')
context.service.logout('token.with.invalid.structure'),
).rejects.toThrow('Token inválido ou não pode ser decodificado');
expect(context.mockSessionManagementService.terminateSession).not.toHaveBeenCalled();
expect(context.mockTokenBlacklistService.addToBlacklist).not.toHaveBeenCalled();
expect(
context.mockSessionManagementService.terminateSession,
).not.toHaveBeenCalled();
expect(
context.mockTokenBlacklistService.addToBlacklist,
).not.toHaveBeenCalled();
});
it('should ensure token is always blacklisted on success', async () => {
@@ -235,8 +263,12 @@ describe('AuthService - logout', () => {
await context.service.logout('valid.token');
expect(context.mockTokenBlacklistService.addToBlacklist).toHaveBeenCalledWith('valid.token');
expect(context.mockTokenBlacklistService.addToBlacklist).toHaveBeenCalledTimes(1);
expect(
context.mockTokenBlacklistService.addToBlacklist,
).toHaveBeenCalledWith('valid.token');
expect(
context.mockTokenBlacklistService.addToBlacklist,
).toHaveBeenCalledTimes(1);
});
it('should handle race condition when token becomes blacklisted between check and add', async () => {
@@ -248,13 +280,17 @@ describe('AuthService - logout', () => {
*/
context.mockTokenBlacklistService.isBlacklisted.mockResolvedValue(false);
context.mockTokenBlacklistService.addToBlacklist.mockRejectedValue(
new Error('Token já está na blacklist')
new Error('Token já está na blacklist'),
);
await context.service.logout('token.with.race.condition');
expect(context.mockTokenBlacklistService.isBlacklisted).toHaveBeenCalledWith('token.with.race.condition');
expect(context.mockTokenBlacklistService.addToBlacklist).toHaveBeenCalledWith('token.with.race.condition');
expect(
context.mockTokenBlacklistService.isBlacklisted,
).toHaveBeenCalledWith('token.with.race.condition');
expect(
context.mockTokenBlacklistService.addToBlacklist,
).toHaveBeenCalledWith('token.with.race.condition');
});
it('should throw error if addToBlacklist fails with non-blacklist error', async () => {
@@ -265,15 +301,21 @@ describe('AuthService - logout', () => {
*/
context.mockTokenBlacklistService.isBlacklisted.mockResolvedValue(false);
context.mockTokenBlacklistService.addToBlacklist.mockRejectedValue(
new Error('Erro de conexão com Redis')
new Error('Erro de conexão com Redis'),
);
await expect(
context.service.logout('token.with.blacklist.error')
).rejects.toThrow('Falha ao adicionar token à blacklist: Erro de conexão com Redis');
context.service.logout('token.with.blacklist.error'),
).rejects.toThrow(
'Falha ao adicionar token à blacklist: Erro de conexão com Redis',
);
expect(context.mockTokenBlacklistService.isBlacklisted).toHaveBeenCalledWith('token.with.blacklist.error');
expect(context.mockTokenBlacklistService.addToBlacklist).toHaveBeenCalledWith('token.with.blacklist.error');
expect(
context.mockTokenBlacklistService.isBlacklisted,
).toHaveBeenCalledWith('token.with.blacklist.error');
expect(
context.mockTokenBlacklistService.addToBlacklist,
).toHaveBeenCalledWith('token.with.blacklist.error');
});
it('should verify isBlacklisted is called before addToBlacklist', async () => {
@@ -286,11 +328,14 @@ describe('AuthService - logout', () => {
await context.service.logout('valid.token');
const isBlacklistedCallOrder = context.mockTokenBlacklistService.isBlacklisted.mock.invocationCallOrder[0];
const addToBlacklistCallOrder = context.mockTokenBlacklistService.addToBlacklist.mock.invocationCallOrder[0];
const isBlacklistedCallOrder =
context.mockTokenBlacklistService.isBlacklisted.mock
.invocationCallOrder[0];
const addToBlacklistCallOrder =
context.mockTokenBlacklistService.addToBlacklist.mock
.invocationCallOrder[0];
expect(isBlacklistedCallOrder).toBeLessThan(addToBlacklistCallOrder);
});
});
});

View File

@@ -19,7 +19,9 @@ describe('AuthService - refreshAccessToken', () => {
situacao: 'A',
dataDesligamento: null,
});
context.mockSessionManagementService.isSessionActive.mockResolvedValue(true);
context.mockSessionManagementService.isSessionActive.mockResolvedValue(
true,
);
});
afterEach(() => {
@@ -29,7 +31,7 @@ describe('AuthService - refreshAccessToken', () => {
describe('refreshAccessToken - Tests that expose problems', () => {
/**
* NOTA: Estes testes identificam problemas no método refreshAccessToken.
*
*
* PROBLEMAS IDENTIFICADOS:
* 1. Não valida refresh token antes de processar
* 2. Não valida dados retornados pelo refresh token service
@@ -40,35 +42,43 @@ describe('AuthService - refreshAccessToken', () => {
*/
it('should reject empty refresh token', async () => {
await expect(
context.service.refreshAccessToken('')
).rejects.toThrow('Refresh token não pode estar vazio');
await expect(context.service.refreshAccessToken('')).rejects.toThrow(
'Refresh token não pode estar vazio',
);
expect(context.mockRefreshTokenService.validateRefreshToken).not.toHaveBeenCalled();
expect(
context.mockRefreshTokenService.validateRefreshToken,
).not.toHaveBeenCalled();
});
it('should reject null refresh token', async () => {
await expect(
context.service.refreshAccessToken(null as any)
context.service.refreshAccessToken(null as any),
).rejects.toThrow('Refresh token não pode estar vazio');
expect(context.mockRefreshTokenService.validateRefreshToken).not.toHaveBeenCalled();
expect(
context.mockRefreshTokenService.validateRefreshToken,
).not.toHaveBeenCalled();
});
it('should reject undefined refresh token', async () => {
await expect(
context.service.refreshAccessToken(undefined as any)
context.service.refreshAccessToken(undefined as any),
).rejects.toThrow('Refresh token não pode estar vazio');
expect(context.mockRefreshTokenService.validateRefreshToken).not.toHaveBeenCalled();
expect(
context.mockRefreshTokenService.validateRefreshToken,
).not.toHaveBeenCalled();
});
it('should reject whitespace-only refresh token', async () => {
await expect(
context.service.refreshAccessToken(' ')
).rejects.toThrow('Refresh token não pode estar vazio');
await expect(context.service.refreshAccessToken(' ')).rejects.toThrow(
'Refresh token não pode estar vazio',
);
expect(context.mockRefreshTokenService.validateRefreshToken).not.toHaveBeenCalled();
expect(
context.mockRefreshTokenService.validateRefreshToken,
).not.toHaveBeenCalled();
});
it('should validate tokenData has required id field', async () => {
@@ -77,15 +87,17 @@ describe('AuthService - refreshAccessToken', () => {
} as any);
await expect(
context.service.refreshAccessToken('valid.refresh.token')
context.service.refreshAccessToken('valid.refresh.token'),
).rejects.toThrow('Dados do refresh token inválidos');
});
it('should validate tokenData is not null', async () => {
context.mockRefreshTokenService.validateRefreshToken.mockResolvedValue(null);
context.mockRefreshTokenService.validateRefreshToken.mockResolvedValue(
null,
);
await expect(
context.service.refreshAccessToken('valid.refresh.token')
context.service.refreshAccessToken('valid.refresh.token'),
).rejects.toThrow('Dados do refresh token inválidos');
});
@@ -101,7 +113,7 @@ describe('AuthService - refreshAccessToken', () => {
});
await expect(
context.service.refreshAccessToken('valid.refresh.token')
context.service.refreshAccessToken('valid.refresh.token'),
).rejects.toThrow('Dados do usuário incompletos');
});
@@ -117,7 +129,7 @@ describe('AuthService - refreshAccessToken', () => {
});
await expect(
context.service.refreshAccessToken('valid.refresh.token')
context.service.refreshAccessToken('valid.refresh.token'),
).rejects.toThrow('Dados do usuário incompletos');
});
@@ -133,7 +145,7 @@ describe('AuthService - refreshAccessToken', () => {
});
await expect(
context.service.refreshAccessToken('valid.refresh.token')
context.service.refreshAccessToken('valid.refresh.token'),
).rejects.toThrow('Dados do usuário incompletos');
});
@@ -141,7 +153,7 @@ describe('AuthService - refreshAccessToken', () => {
context.mockJwtService.sign.mockReturnValue('');
await expect(
context.service.refreshAccessToken('valid.refresh.token')
context.service.refreshAccessToken('valid.refresh.token'),
).rejects.toThrow('Falha ao gerar novo token de acesso');
});
@@ -149,7 +161,7 @@ describe('AuthService - refreshAccessToken', () => {
context.mockJwtService.sign.mockReturnValue(null as any);
await expect(
context.service.refreshAccessToken('valid.refresh.token')
context.service.refreshAccessToken('valid.refresh.token'),
).rejects.toThrow('Falha ao gerar novo token de acesso');
});
@@ -159,10 +171,12 @@ describe('AuthService - refreshAccessToken', () => {
sessionId: 'expired-session',
});
context.mockSessionManagementService.isSessionActive = jest.fn().mockResolvedValue(false);
context.mockSessionManagementService.isSessionActive = jest
.fn()
.mockResolvedValue(false);
await expect(
context.service.refreshAccessToken('valid.refresh.token')
context.service.refreshAccessToken('valid.refresh.token'),
).rejects.toThrow('Sessão não está mais ativa');
});
@@ -178,7 +192,7 @@ describe('AuthService - refreshAccessToken', () => {
});
await expect(
context.service.refreshAccessToken('valid.refresh.token')
context.service.refreshAccessToken('valid.refresh.token'),
).rejects.toThrow('ID de vendedor inválido');
});
@@ -186,24 +200,30 @@ describe('AuthService - refreshAccessToken', () => {
const hugeToken = 'a'.repeat(100000);
await expect(
context.service.refreshAccessToken(hugeToken)
context.service.refreshAccessToken(hugeToken),
).rejects.toThrow('Refresh token muito longo');
expect(context.mockRefreshTokenService.validateRefreshToken).not.toHaveBeenCalled();
expect(
context.mockRefreshTokenService.validateRefreshToken,
).not.toHaveBeenCalled();
});
it('should sanitize refresh token input', async () => {
const maliciousToken = "'; DROP TABLE users; --";
await expect(
context.service.refreshAccessToken(maliciousToken)
context.service.refreshAccessToken(maliciousToken),
).rejects.toThrow('Formato de refresh token inválido');
expect(context.mockRefreshTokenService.validateRefreshToken).not.toHaveBeenCalled();
expect(
context.mockRefreshTokenService.validateRefreshToken,
).not.toHaveBeenCalled();
});
it('should include only required fields in response', async () => {
const result = await context.service.refreshAccessToken('valid.refresh.token');
const result = await context.service.refreshAccessToken(
'valid.refresh.token',
);
expect(result).toHaveProperty('accessToken');
expect(result).toHaveProperty('expiresIn');
@@ -213,7 +233,9 @@ describe('AuthService - refreshAccessToken', () => {
});
it('should validate expiresIn is correct', async () => {
const result = await context.service.refreshAccessToken('valid.refresh.token');
const result = await context.service.refreshAccessToken(
'valid.refresh.token',
);
expect(result.expiresIn).toBe(28800);
expect(result.expiresIn).toBeGreaterThan(0);
@@ -231,7 +253,7 @@ describe('AuthService - refreshAccessToken', () => {
});
await expect(
context.service.refreshAccessToken('valid.refresh.token')
context.service.refreshAccessToken('valid.refresh.token'),
).rejects.toThrow();
});
@@ -244,7 +266,7 @@ describe('AuthService - refreshAccessToken', () => {
const results = await Promise.all(promises);
results.forEach(result => {
results.forEach((result) => {
expect(result).toHaveProperty('accessToken');
expect(result.accessToken).toBeTruthy();
});
@@ -262,9 +284,8 @@ describe('AuthService - refreshAccessToken', () => {
});
await expect(
context.service.refreshAccessToken('valid.refresh.token')
context.service.refreshAccessToken('valid.refresh.token'),
).rejects.toThrow('Usuário inválido ou inativo');
});
});
});

View File

@@ -24,14 +24,17 @@ import { RateLimitingGuard } from '../guards/rate-limiting.guard';
import { RateLimitingService } from '../services/rate-limiting.service';
import { RefreshTokenService } from '../services/refresh-token.service';
import { SessionManagementService } from '../services/session-management.service';
import { RefreshTokenDto, RefreshTokenResponseDto } from './dto/refresh-token.dto';
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
import {
LoginAuditFiltersDto,
LoginAuditResponseDto,
LoginStatsDto,
LoginStatsFiltersDto,
} from './dto/login-audit.dto';
import {
ApiTags,
@@ -66,9 +69,12 @@ export class AuthController {
})
@ApiUnauthorizedResponse({ description: 'Usuário ou senha inválidos' })
@ApiTooManyRequestsResponse({ description: 'Muitas tentativas de login' })
async login(@Body() dto: LoginDto, @Request() req): Promise<LoginResponseDto> {
async login(
@Body() dto: LoginDto,
@Request() req,
): Promise<LoginResponseDto> {
const ip = this.getClientIp(req);
const command = new AuthenticateUserCommand(dto.username, dto.password);
const result = await this.commandBus.execute(command);
@@ -76,7 +82,7 @@ export class AuthController {
if (!result.success) {
await this.rateLimitingService.recordAttempt(ip, false);
await this.loginAuditService.logLoginAttempt({
username: dto.username,
ipAddress: ip,
@@ -84,7 +90,7 @@ export class AuthController {
success: false,
failureReason: result.error,
});
throw new HttpException(
new ResultModel(false, result.error, null, result.error),
HttpStatus.UNAUTHORIZED,
@@ -94,19 +100,23 @@ export class AuthController {
await this.rateLimitingService.recordAttempt(ip, true);
const user = result.data;
/**
* Verifica se o usuário já possui uma sessão ativa
*/
const existingSession = await this.sessionManagementService.hasActiveSession(user.id);
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);
await this.sessionManagementService.terminateSession(
user.id,
existingSession.sessionId,
);
}
const session = await this.sessionManagementService.createSession(
user.id,
ip,
@@ -161,7 +171,6 @@ export class AuthController {
);
}
@Post('logout')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@@ -170,10 +179,15 @@ export class AuthController {
@ApiUnauthorizedResponse({ description: 'Token inválido ou expirado' })
async logout(@Request() req): Promise<{ message: string }> {
const token = req.headers.authorization?.replace('Bearer ', '');
if (!token) {
throw new HttpException(
new ResultModel(false, 'Token não fornecido', null, 'Token não fornecido'),
new ResultModel(
false,
'Token não fornecido',
null,
'Token não fornecido',
),
HttpStatus.UNAUTHORIZED,
);
}
@@ -192,8 +206,12 @@ export class AuthController {
description: 'Token renovado com sucesso',
type: RefreshTokenResponseDto,
})
@ApiUnauthorizedResponse({ description: 'Refresh token inválido ou expirado' })
async refreshToken(@Body() dto: RefreshTokenDto): Promise<RefreshTokenResponseDto> {
@ApiUnauthorizedResponse({
description: 'Refresh token inválido ou expirado',
})
async refreshToken(
@Body() dto: RefreshTokenDto,
): Promise<RefreshTokenResponseDto> {
const result = await this.authService.refreshAccessToken(dto.refreshToken);
return result;
}
@@ -210,15 +228,20 @@ export class AuthController {
async getSessions(@Request() req): Promise<SessionsResponseDto> {
const userId = req.user.id;
const currentSessionId = req.user.sessionId;
const sessions = await this.sessionManagementService.getActiveSessions(userId, currentSessionId);
const sessions = await this.sessionManagementService.getActiveSessions(
userId,
currentSessionId,
);
return {
sessions: sessions.map(session => ({
sessions: sessions.map((session) => ({
sessionId: session.sessionId,
ipAddress: session.ipAddress,
userAgent: session.userAgent,
createdAt: DateUtil.toBrazilISOString(new Date(session.createdAt)),
lastActivity: DateUtil.toBrazilISOString(new Date(session.lastActivity)),
lastActivity: DateUtil.toBrazilISOString(
new Date(session.lastActivity),
),
isCurrent: session.sessionId === currentSessionId,
})),
total: sessions.length,
@@ -238,7 +261,7 @@ export class AuthController {
): Promise<{ message: string }> {
const userId = req.user.id;
await this.sessionManagementService.terminateSession(userId, sessionId);
return {
message: 'Sessão encerrada com sucesso',
};
@@ -253,7 +276,7 @@ export class AuthController {
async terminateAllSessions(@Request() req): Promise<{ message: string }> {
const userId = req.user.id;
await this.sessionManagementService.terminateAllSessions(userId);
return {
message: 'Todas as sessões foram encerradas com sucesso',
};
@@ -273,7 +296,7 @@ export class AuthController {
@Request() req,
): Promise<LoginAuditResponseDto> {
const userId = req.user.id;
const auditFilters = {
...filters,
userId: filters.userId || userId,
@@ -282,9 +305,9 @@ export class AuthController {
};
const logs = await this.loginAuditService.getLoginLogs(auditFilters);
return {
logs: logs.map(log => ({
logs: logs.map((log) => ({
...log,
timestamp: DateUtil.toBrazilISOString(log.timestamp),
})),
@@ -309,12 +332,12 @@ export class AuthController {
): Promise<LoginStatsDto> {
const userId = req.user.id;
const days = filters.days || 7;
const stats = await this.loginAuditService.getLoginStats(
filters.userId || userId,
days,
);
return stats;
}
@@ -333,13 +356,12 @@ export class AuthController {
ipAddress: { type: 'string' },
userAgent: { type: 'string' },
createdAt: { type: 'string' },
lastActivity: { type: 'string' }
}
}
}
}
lastActivity: { type: 'string' },
},
},
},
},
})
@Get('session/status')
async checkSessionStatus(@Query('username') username: string): Promise<{
hasActiveSession: boolean;
@@ -353,7 +375,12 @@ export class AuthController {
}> {
if (!username) {
throw new HttpException(
new ResultModel(false, 'Username é obrigatório', null, 'Username é obrigatório'),
new ResultModel(
false,
'Username é obrigatório',
null,
'Username é obrigatório',
),
HttpStatus.BAD_REQUEST,
);
}
@@ -362,15 +389,17 @@ export class AuthController {
* 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);
const activeSession = await this.sessionManagementService.hasActiveSession(
user.id,
);
if (!activeSession) {
return {
hasActiveSession: false,
@@ -383,8 +412,12 @@ export class AuthController {
sessionId: activeSession.sessionId,
ipAddress: activeSession.ipAddress,
userAgent: activeSession.userAgent,
createdAt: DateUtil.toBrazilISOString(new Date(activeSession.createdAt)),
lastActivity: DateUtil.toBrazilISOString(new Date(activeSession.lastActivity)),
createdAt: DateUtil.toBrazilISOString(
new Date(activeSession.createdAt),
),
lastActivity: DateUtil.toBrazilISOString(
new Date(activeSession.lastActivity),
),
},
};
}

View File

@@ -35,14 +35,14 @@ import { LoginAuditService } from '../services/login-audit.service';
],
controllers: [AuthController],
providers: [
AuthService,
JwtStrategy,
TokenBlacklistService,
RateLimitingService,
AuthService,
JwtStrategy,
TokenBlacklistService,
RateLimitingService,
RefreshTokenService,
SessionManagementService,
LoginAuditService,
AuthenticateUserHandler
AuthenticateUserHandler,
],
exports: [AuthService],
})

View File

@@ -1,4 +1,8 @@
import { Injectable, UnauthorizedException, BadRequestException } from '@nestjs/common';
import {
Injectable,
UnauthorizedException,
BadRequestException,
} from '@nestjs/common';
import { JwtService, JwtSignOptions } from '@nestjs/jwt';
import { UsersService } from '../users/users.service';
import { JwtPayload } from '../models/jwt-payload.model';
@@ -7,7 +11,6 @@ import { TokenBlacklistService } from '../services/token-blacklist.service';
import { RefreshTokenService } from '../services/refresh-token.service';
import { SessionManagementService } from '../services/session-management.service';
@Injectable()
export class AuthService {
constructor(
@@ -23,7 +26,14 @@ export class AuthService {
* Cria um token JWT com validação de todos os parâmetros de entrada
* @throws BadRequestException quando os parâmetros são inválidos
*/
async createToken(id: number, sellerId: number, username: string, email: string, storeId: string, sessionId?: string) {
async createToken(
id: number,
sellerId: number,
username: string,
email: string,
storeId: string,
sessionId?: string,
) {
this.validateTokenParameters(id, sellerId, username, email, storeId);
const user: JwtPayload = {
@@ -42,7 +52,13 @@ export class AuthService {
* Valida os parâmetros de entrada para criação de token
* @private
*/
private validateTokenParameters(id: number, sellerId: number, username: string, email: string, storeId: string): void {
private validateTokenParameters(
id: number,
sellerId: number,
username: string,
email: string,
storeId: string,
): void {
if (!id || id <= 0) {
throw new BadRequestException('ID de usuário inválido');
}
@@ -64,7 +80,9 @@ export class AuthService {
}
if (/['";\\]/.test(username)) {
throw new BadRequestException('Nome de usuário contém caracteres inválidos');
throw new BadRequestException(
'Nome de usuário contém caracteres inválidos',
);
}
if (!email || typeof email !== 'string' || !email.trim()) {
@@ -77,7 +95,7 @@ export class AuthService {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const multipleAtSymbols = (email.match(/@/g) || []).length > 1;
if (!emailRegex.test(email) || multipleAtSymbols) {
throw new BadRequestException('Formato de email inválido');
}
@@ -92,16 +110,41 @@ export class AuthService {
* @throws BadRequestException quando os parâmetros são inválidos
* @throws Error quando os tokens gerados são inválidos
*/
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);
if (!accessToken || typeof accessToken !== 'string' || !accessToken.trim()) {
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,
);
if (
!accessToken ||
typeof accessToken !== 'string' ||
!accessToken.trim()
) {
throw new Error('Token de acesso inválido gerado');
}
const refreshToken = await this.refreshTokenService.generateRefreshToken(id, sessionId);
if (!refreshToken || typeof refreshToken !== 'string' || !refreshToken.trim()) {
const refreshToken = await this.refreshTokenService.generateRefreshToken(
id,
sessionId,
);
if (
!refreshToken ||
typeof refreshToken !== 'string' ||
!refreshToken.trim()
) {
throw new Error('Refresh token inválido gerado');
}
@@ -121,8 +164,10 @@ export class AuthService {
async refreshAccessToken(refreshToken: string) {
this.validateRefreshTokenInput(refreshToken);
const tokenData = await this.refreshTokenService.validateRefreshToken(refreshToken);
const tokenData = await this.refreshTokenService.validateRefreshToken(
refreshToken,
);
if (!tokenData || !tokenData.id) {
throw new BadRequestException('Dados do refresh token inválidos');
}
@@ -135,10 +180,11 @@ export class AuthService {
this.validateUserDataForToken(user);
if (tokenData.sessionId) {
const isSessionActive = await this.sessionManagementService.isSessionActive(
user.id,
tokenData.sessionId
);
const isSessionActive =
await this.sessionManagementService.isSessionActive(
user.id,
tokenData.sessionId,
);
if (!isSessionActive) {
throw new UnauthorizedException('Sessão não está mais ativa');
}
@@ -150,10 +196,14 @@ export class AuthService {
user.name,
user.email,
user.storeId,
tokenData.sessionId
tokenData.sessionId,
);
if (!newAccessToken || typeof newAccessToken !== 'string' || !newAccessToken.trim()) {
if (
!newAccessToken ||
typeof newAccessToken !== 'string' ||
!newAccessToken.trim()
) {
throw new Error('Falha ao gerar novo token de acesso');
}
@@ -168,7 +218,11 @@ export class AuthService {
* @private
*/
private validateRefreshTokenInput(refreshToken: string): void {
if (!refreshToken || typeof refreshToken !== 'string' || !refreshToken.trim()) {
if (
!refreshToken ||
typeof refreshToken !== 'string' ||
!refreshToken.trim()
) {
throw new BadRequestException('Refresh token não pode estar vazio');
}
@@ -187,18 +241,32 @@ export class AuthService {
*/
private validateUserDataForToken(user: any): void {
if (!user.name || typeof user.name !== 'string' || !user.name.trim()) {
throw new BadRequestException('Dados do usuário incompletos: nome não encontrado');
throw new BadRequestException(
'Dados do usuário incompletos: nome não encontrado',
);
}
if (!user.email || typeof user.email !== 'string' || !user.email.trim()) {
throw new BadRequestException('Dados do usuário incompletos: email não encontrado');
throw new BadRequestException(
'Dados do usuário incompletos: email não encontrado',
);
}
if (!user.storeId || typeof user.storeId !== 'string' || !user.storeId.trim()) {
throw new BadRequestException('Dados do usuário incompletos: storeId não encontrado');
if (
!user.storeId ||
typeof user.storeId !== 'string' ||
!user.storeId.trim()
) {
throw new BadRequestException(
'Dados do usuário incompletos: storeId não encontrado',
);
}
if (user.sellerId !== null && user.sellerId !== undefined && user.sellerId < 0) {
if (
user.sellerId !== null &&
user.sellerId !== undefined &&
user.sellerId < 0
) {
throw new BadRequestException('ID de vendedor inválido');
}
}
@@ -228,11 +296,15 @@ export class AuthService {
try {
decoded = this.jwtService.decode(token) as JwtPayload;
} catch (error) {
throw new BadRequestException('Token inválido ou não pode ser decodificado');
throw new BadRequestException(
'Token inválido ou não pode ser decodificado',
);
}
if (!decoded || !decoded.id) {
throw new BadRequestException('Token inválido ou não pode ser decodificado');
throw new BadRequestException(
'Token inválido ou não pode ser decodificado',
);
}
if (decoded.id <= 0) {
@@ -241,25 +313,34 @@ export class AuthService {
if (decoded.sessionId && decoded.id && decoded.sessionId.trim()) {
try {
await this.sessionManagementService.terminateSession(decoded.id, decoded.sessionId);
await this.sessionManagementService.terminateSession(
decoded.id,
decoded.sessionId,
);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
const errorMessage =
error instanceof Error ? error.message : String(error);
if (errorMessage.includes('Sessão não encontrada')) {
throw new Error('Sessão não encontrada');
}
}
}
const isAlreadyBlacklisted = await this.tokenBlacklistService.isBlacklisted(token);
const isAlreadyBlacklisted = await this.tokenBlacklistService.isBlacklisted(
token,
);
if (!isAlreadyBlacklisted) {
try {
await this.tokenBlacklistService.addToBlacklist(token);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
const errorMessage =
error instanceof Error ? error.message : String(error);
if (errorMessage.includes('já está na blacklist')) {
return;
}
throw new Error(`Falha ao adicionar token à blacklist: ${errorMessage}`);
throw new Error(
`Falha ao adicionar token à blacklist: ${errorMessage}`,
);
}
}
}
@@ -289,4 +370,4 @@ export class AuthService {
async findUserByUsername(username: string) {
return this.userRepository.findByUsername(username);
}
}
}

View File

@@ -1,7 +1,6 @@
export class AuthenticateUserCommand {
constructor(
public readonly username: string,
public readonly password: string,
) {}
}
constructor(
public readonly username: string,
public readonly password: string,
) {}
}

View File

@@ -7,13 +7,18 @@ import { UserModel } from 'src/core/models/user.model';
@CommandHandler(AuthenticateUserCommand)
@Injectable()
export class AuthenticateUserHandler implements ICommandHandler<AuthenticateUserCommand> {
export class AuthenticateUserHandler
implements ICommandHandler<AuthenticateUserCommand>
{
constructor(private readonly userRepository: UserRepository) {}
async execute(command: AuthenticateUserCommand): Promise<Result<UserModel>> {
const { username, password } = command;
const user = await this.userRepository.findByUsernameAndPassword(username, password);
const user = await this.userRepository.findByUsernameAndPassword(
username,
password,
);
if (!user) {
return Result.fail('Usuário ou senha inválidos');
@@ -30,7 +35,6 @@ export class AuthenticateUserHandler implements ICommandHandler<AuthenticateUser
if (user.situacao === 'B') {
return Result.fail('Usuário bloqueado, login não permitido!');
}
return Result.ok(user);
}

View File

@@ -1,5 +1,13 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsOptional, IsNumber, IsString, IsBoolean, IsDateString, Min, Max } from 'class-validator';
import {
IsOptional,
IsNumber,
IsString,
IsBoolean,
IsDateString,
Min,
Max,
} from 'class-validator';
import { Type } from 'class-transformer';
export class LoginAuditFiltersDto {
@@ -19,7 +27,10 @@ export class LoginAuditFiltersDto {
@IsString()
ipAddress?: string;
@ApiProperty({ description: 'Filtrar apenas logins bem-sucedidos', required: false })
@ApiProperty({
description: 'Filtrar apenas logins bem-sucedidos',
required: false,
})
@IsOptional()
@IsBoolean()
@Type(() => Boolean)
@@ -35,7 +46,12 @@ export class LoginAuditFiltersDto {
@IsDateString()
endDate?: string;
@ApiProperty({ description: 'Número de registros por página', required: false, minimum: 1, maximum: 1000 })
@ApiProperty({
description: 'Número de registros por página',
required: false,
minimum: 1,
maximum: 1000,
})
@IsOptional()
@IsNumber()
@Type(() => Number)
@@ -43,7 +59,11 @@ export class LoginAuditFiltersDto {
@Max(1000)
limit?: number;
@ApiProperty({ description: 'Offset para paginação', required: false, minimum: 0 })
@ApiProperty({
description: 'Offset para paginação',
required: false,
minimum: 0,
})
@IsOptional()
@IsNumber()
@Type(() => Number)
@@ -84,7 +104,10 @@ export class LoginAuditLogDto {
}
export class LoginAuditResponseDto {
@ApiProperty({ description: 'Lista de logs de login', type: [LoginAuditLogDto] })
@ApiProperty({
description: 'Lista de logs de login',
type: [LoginAuditLogDto],
})
logs: LoginAuditLogDto[];
@ApiProperty({ description: 'Total de registros encontrados' })
@@ -114,22 +137,30 @@ export class LoginStatsDto {
topIps: Array<{ ip: string; count: number }>;
@ApiProperty({ description: 'Estatísticas diárias' })
dailyStats: Array<{
date: string;
attempts: number;
successes: number;
failures: number;
dailyStats: Array<{
date: string;
attempts: number;
successes: number;
failures: number;
}>;
}
export class LoginStatsFiltersDto {
@ApiProperty({ description: 'ID do usuário para estatísticas', required: false })
@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 })
@ApiProperty({
description: 'Número de dias para análise',
required: false,
minimum: 1,
maximum: 365,
})
@IsOptional()
@IsNumber()
@Type(() => Number)

View File

@@ -50,7 +50,7 @@ describe('RateLimitingGuard - Tests that expose problems', () => {
describe('canActivate', () => {
/**
* NOTA: Estes testes identificam problemas no método canActivate.
*
*
* PROBLEMAS IDENTIFICADOS:
* 1. Não valida se IP extraído é válido
* 2. Não valida se rate limiting service retorna dados válidos
@@ -196,7 +196,7 @@ describe('RateLimitingGuard - Tests that expose problems', () => {
mockGetRequest.mockReturnValue(request);
mockRateLimitingService.isAllowed.mockRejectedValue(
new Error('Erro de conexão com Redis')
new Error('Erro de conexão com Redis'),
);
try {
@@ -225,7 +225,7 @@ describe('RateLimitingGuard - Tests that expose problems', () => {
mockGetRequest.mockReturnValue(request);
mockRateLimitingService.isAllowed.mockResolvedValue(false);
mockRateLimitingService.getAttemptInfo.mockRejectedValue(
new Error('Erro ao buscar informações')
new Error('Erro ao buscar informações'),
);
try {
@@ -336,7 +336,9 @@ describe('RateLimitingGuard - Tests that expose problems', () => {
const result = await guard.canActivate(mockExecutionContext);
expect(result).toBe(true);
expect(mockRateLimitingService.isAllowed).toHaveBeenCalledWith('192.168.1.1');
expect(mockRateLimitingService.isAllowed).toHaveBeenCalledWith(
'192.168.1.1',
);
});
it('should handle concurrent requests with same IP', async () => {
@@ -363,7 +365,7 @@ describe('RateLimitingGuard - Tests that expose problems', () => {
const results = await Promise.all(promises);
results.forEach(result => {
results.forEach((result) => {
expect(result).toBe(true);
});
});
@@ -394,7 +396,9 @@ describe('RateLimitingGuard - Tests that expose problems', () => {
fail('Deveria ter lançado exceção');
} catch (error) {
expect(error).toBeInstanceOf(HttpException);
expect((error as HttpException).getStatus()).toBe(HttpStatus.TOO_MANY_REQUESTS);
expect((error as HttpException).getStatus()).toBe(
HttpStatus.TOO_MANY_REQUESTS,
);
}
});
@@ -419,7 +423,9 @@ describe('RateLimitingGuard - Tests that expose problems', () => {
fail('Deveria ter lançado exceção');
} catch (error) {
const response = (error as HttpException).getResponse() as any;
expect(response.error).toBe('Muitas tentativas de login. Tente novamente em alguns minutos.');
expect(response.error).toBe(
'Muitas tentativas de login. Tente novamente em alguns minutos.',
);
expect(response.success).toBe(false);
}
});
@@ -512,7 +518,9 @@ describe('RateLimitingGuard - Tests that expose problems', () => {
const result = await guard.canActivate(mockExecutionContext);
expect(result).toBe(true);
expect(mockRateLimitingService.isAllowed).toHaveBeenCalledWith('2001:0db8:85a3:0000:0000:8a2e:0370:7334');
expect(mockRateLimitingService.isAllowed).toHaveBeenCalledWith(
'2001:0db8:85a3:0000:0000:8a2e:0370:7334',
);
});
it('should reject invalid IPv6 format', async () => {
@@ -556,7 +564,9 @@ describe('RateLimitingGuard - Tests that expose problems', () => {
await guard.canActivate(mockExecutionContext);
expect(mockRateLimitingService.isAllowed).toHaveBeenCalledWith('192.168.1.1');
expect(mockRateLimitingService.isAllowed).toHaveBeenCalledWith(
'192.168.1.1',
);
});
it('should fallback to connection.remoteAddress when x-forwarded-for is missing', async () => {
@@ -572,7 +582,9 @@ describe('RateLimitingGuard - Tests that expose problems', () => {
await guard.canActivate(mockExecutionContext);
expect(mockRateLimitingService.isAllowed).toHaveBeenCalledWith('10.0.0.1');
expect(mockRateLimitingService.isAllowed).toHaveBeenCalledWith(
'10.0.0.1',
);
});
it('should use default IP when all sources are missing', async () => {
@@ -603,4 +615,3 @@ describe('RateLimitingGuard - Tests that expose problems', () => {
});
});
});

View File

@@ -1,4 +1,10 @@
import { Injectable, CanActivate, ExecutionContext, HttpException, HttpStatus } from '@nestjs/common';
import {
Injectable,
CanActivate,
ExecutionContext,
HttpException,
HttpStatus,
} from '@nestjs/common';
import { RateLimitingService } from '../services/rate-limiting.service';
@Injectable()
@@ -19,7 +25,8 @@ export class RateLimitingGuard implements CanActivate {
try {
isAllowed = await this.rateLimitingService.isAllowed(ip);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
const errorMessage =
error instanceof Error ? error.message : String(error);
throw new HttpException(
{
success: false,
@@ -30,13 +37,14 @@ export class RateLimitingGuard implements CanActivate {
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
if (!isAllowed) {
let attemptInfo;
try {
attemptInfo = await this.rateLimitingService.getAttemptInfo(ip);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
const errorMessage =
error instanceof Error ? error.message : String(error);
throw new HttpException(
{
success: false,
@@ -49,11 +57,12 @@ export class RateLimitingGuard implements CanActivate {
}
this.validateAttemptInfo(attemptInfo);
throw new HttpException(
{
success: false,
error: 'Muitas tentativas de login. Tente novamente em alguns minutos.',
error:
'Muitas tentativas de login. Tente novamente em alguns minutos.',
data: null,
details: {
attempts: attemptInfo.attempts,
@@ -73,13 +82,16 @@ export class RateLimitingGuard implements CanActivate {
* @returns Endereço IP do cliente ou '127.0.0.1' se não encontrado
*/
private getClientIp(request: any): string {
const forwardedFor = request.headers['x-forwarded-for']?.split(',')[0]?.trim();
const forwardedFor = request.headers['x-forwarded-for']
?.split(',')[0]
?.trim();
const realIp = request.headers['x-real-ip']?.trim();
const connectionIp = request.connection?.remoteAddress;
const socketIp = request.socket?.remoteAddress;
const requestIp = request.ip;
const rawIp = forwardedFor || realIp || connectionIp || socketIp || requestIp;
const rawIp =
forwardedFor || realIp || connectionIp || socketIp || requestIp;
if (rawIp === null || rawIp === undefined) {
return '';
@@ -90,7 +102,7 @@ export class RateLimitingGuard implements CanActivate {
}
const trimmedIp = rawIp.trim();
if (trimmedIp === '') {
return '';
}
@@ -144,7 +156,11 @@ export class RateLimitingGuard implements CanActivate {
return;
}
if (!ipv4Regex.test(ip) && !ipv6Regex.test(ip) && !ipv6CompressedRegex.test(ip)) {
if (
!ipv4Regex.test(ip) &&
!ipv6Regex.test(ip) &&
!ipv6CompressedRegex.test(ip)
) {
if (!this.isValidIpv4(ip) && !this.isValidIpv6(ip)) {
throw new HttpException(
{
@@ -166,7 +182,7 @@ export class RateLimitingGuard implements CanActivate {
const parts = ip.split('.');
if (parts.length !== 4) return false;
return parts.every(part => {
return parts.every((part) => {
const num = parseInt(part, 10);
return !isNaN(num) && num >= 0 && num <= 255;
});
@@ -180,17 +196,17 @@ export class RateLimitingGuard implements CanActivate {
if (ip.includes('::')) {
const parts = ip.split('::');
if (parts.length > 2) return false;
const leftParts = parts[0] ? parts[0].split(':') : [];
const rightParts = parts[1] ? parts[1].split(':') : [];
return (leftParts.length + rightParts.length) <= 8;
return leftParts.length + rightParts.length <= 8;
}
const parts = ip.split(':');
if (parts.length !== 8) return false;
return parts.every(part => {
return parts.every((part) => {
if (!part) return false;
return /^[0-9a-fA-F]{1,4}$/.test(part);
});
@@ -223,8 +239,11 @@ export class RateLimitingGuard implements CanActivate {
);
}
if (attemptInfo.remainingTime !== undefined &&
(typeof attemptInfo.remainingTime !== 'number' || attemptInfo.remainingTime < 0)) {
if (
attemptInfo.remainingTime !== undefined &&
(typeof attemptInfo.remainingTime !== 'number' ||
attemptInfo.remainingTime < 0)
) {
throw new HttpException(
{
success: false,

View File

@@ -1,16 +1,15 @@
export class Result<T> {
private constructor(
public readonly success: boolean,
public readonly data?: T,
public readonly error?: string,
) {}
static ok<U>(data: U): Result<U> {
return new Result<U>(true, data);
}
static fail<U>(message: string): Result<U> {
return new Result<U>(false, undefined, message);
}
private constructor(
public readonly success: boolean,
public readonly data?: T,
public readonly error?: string,
) {}
static ok<U>(data: U): Result<U> {
return new Result<U>(true, data);
}
static fail<U>(message: string): Result<U> {
return new Result<U>(false, undefined, message);
}
}

View File

@@ -31,14 +31,14 @@ export class LoginAuditService {
private readonly LOG_PREFIX = 'login_audit';
private readonly LOG_EXPIRY = 30 * 24 * 60 * 60;
constructor(
@Inject('REDIS_CLIENT') private readonly redis: Redis,
) {}
constructor(@Inject('REDIS_CLIENT') private readonly redis: Redis) {}
async logLoginAttempt(log: Omit<LoginAuditLog, 'id' | 'timestamp'>): Promise<void> {
async logLoginAttempt(
log: Omit<LoginAuditLog, 'id' | 'timestamp'>,
): Promise<void> {
const logId = this.generateLogId();
const timestamp = DateUtil.now();
const auditLog: LoginAuditLog = {
...log,
id: logId,
@@ -69,24 +69,26 @@ export class LoginAuditService {
await this.redis.expire(dateLogsKey, this.LOG_EXPIRY);
}
async getLoginLogs(filters: LoginAuditFilters = {}): Promise<LoginAuditLog[]> {
async getLoginLogs(
filters: LoginAuditFilters = {},
): Promise<LoginAuditLog[]> {
const logIds = await this.getLogIds(filters);
const logs: LoginAuditLog[] = [];
for (const logId of logIds) {
const logKey = this.buildLogKey(logId);
const logData = await this.redis.get(logKey);
if (!logData) {
continue;
}
const log: LoginAuditLog = JSON.parse(logData as string);
if (typeof log.timestamp === 'string') {
log.timestamp = new Date(log.timestamp);
}
if (!this.matchesFilters(log, filters)) {
continue;
}
@@ -98,21 +100,29 @@ export class LoginAuditService {
const offset = filters.offset || 0;
const limit = filters.limit || 100;
return logs.slice(offset, offset + limit);
}
async getLoginStats(userId?: number, days: number = 7): Promise<{
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 }>;
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,
@@ -124,38 +134,50 @@ export class LoginAuditService {
}
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,
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 }>,
dailyStats: [] as Array<{
date: string;
attempts: number;
successes: number;
failures: number;
}>,
};
const ipCounts = new Map<string, number>();
logs.forEach(log => {
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 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 };
const dayStats = dailyCounts.get(date) || {
attempts: 0,
successes: 0,
failures: 0,
};
dayStats.attempts++;
if (log.success) {
dayStats.successes++;
dailyCounts.set(date, dayStats);
return;
}
dayStats.failures++;
dailyCounts.set(date, dayStats);
});
@@ -168,9 +190,11 @@ export class LoginAuditService {
}
async cleanupOldLogs(): Promise<void> {
const cutoffDate = new Date(DateUtil.nowTimestamp() - 30 * 24 * 60 * 60 * 1000);
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);
@@ -190,18 +214,20 @@ export class LoginAuditService {
}
if (filters.startDate || filters.endDate) {
const startDate = filters.startDate || new Date(DateUtil.nowTimestamp() - 7 * 24 * 60 * 60 * 1000);
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);
const logIds: string[] = [];
for (const date of dates) {
const dateLogsKey = this.buildDateLogsKey(date);
const dateLogIds = await this.redis.lrange(dateLogsKey, 0, -1);
logIds.push(...dateLogIds);
}
return logIds;
}
@@ -210,7 +236,9 @@ export class LoginAuditService {
}
private generateLogId(): string {
return `${DateUtil.nowTimestamp()}_${Math.random().toString(36).substr(2, 9)}`;
return `${DateUtil.nowTimestamp()}_${Math.random()
.toString(36)
.substr(2, 9)}`;
}
private buildLogKey(logId: string): string {
@@ -233,11 +261,17 @@ export class LoginAuditService {
return `${this.LOG_PREFIX}:date:${date}`;
}
private matchesFilters(log: LoginAuditLog, filters: LoginAuditFilters): boolean {
if (filters.username && !log.username.toLowerCase().includes(filters.username.toLowerCase())) {
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;
}
@@ -256,12 +290,12 @@ export class LoginAuditService {
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

@@ -16,11 +16,12 @@ export class RateLimitingService {
blockDurationMs: 1 * 60 * 1000,
};
constructor(
@Inject(RedisClientToken) private readonly redis: IRedisClient,
) {}
constructor(@Inject(RedisClientToken) private readonly redis: IRedisClient) {}
async isAllowed(ip: string, config?: Partial<RateLimitConfig>): Promise<boolean> {
async isAllowed(
ip: string,
config?: Partial<RateLimitConfig>,
): Promise<boolean> {
const finalConfig = { ...this.defaultConfig, ...config };
const key = this.buildAttemptKey(ip);
const blockKey = this.buildBlockKey(ip);
@@ -51,21 +52,25 @@ export class RateLimitingService {
return {attempts, 0}
`;
const result = await this.redis.eval(
const result = (await this.redis.eval(
luaScript,
2,
key,
blockKey,
finalConfig.maxAttempts,
finalConfig.windowMs,
finalConfig.blockDurationMs
) as [number, number];
finalConfig.blockDurationMs,
)) as [number, number];
const [attempts, isBlockedResult] = result;
return isBlockedResult === 0;
}
async recordAttempt(ip: string, success: boolean, config?: Partial<RateLimitConfig>): Promise<void> {
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);
@@ -98,7 +103,7 @@ export class RateLimitingService {
async clearAttempts(ip: string): Promise<void> {
const key = this.buildAttemptKey(ip);
const blockKey = this.buildBlockKey(ip);
await this.redis.del(key);
await this.redis.del(blockKey);
}

View File

@@ -24,18 +24,21 @@ export class RefreshTokenService {
private readonly jwtService: JwtService,
) {}
async generateRefreshToken(userId: number, sessionId?: string): Promise<string> {
async generateRefreshToken(
userId: number,
sessionId?: string,
): Promise<string> {
const tokenId = randomBytes(32).toString('hex');
const refreshToken = this.jwtService.sign(
{ userId, tokenId, sessionId, type: 'refresh' },
{ expiresIn: '7d' }
{ expiresIn: '7d' },
);
const tokenData: RefreshTokenData = {
userId,
tokenId,
sessionId,
expiresAt: DateUtil.nowTimestamp() + (this.REFRESH_TOKEN_TTL * 1000),
expiresAt: DateUtil.nowTimestamp() + this.REFRESH_TOKEN_TTL * 1000,
createdAt: DateUtil.nowTimestamp(),
};
@@ -50,7 +53,7 @@ export class RefreshTokenService {
async validateRefreshToken(refreshToken: string): Promise<JwtPayload> {
try {
const decoded = this.jwtService.verify(refreshToken) as any;
if (decoded.type !== 'refresh') {
throw new UnauthorizedException('Token inválido');
}
@@ -68,14 +71,14 @@ export class RefreshTokenService {
throw new UnauthorizedException('Refresh token expirado');
}
return {
id: userId,
sellerId: 0,
storeId: '',
username: '',
return {
id: userId,
sellerId: 0,
storeId: '',
username: '',
email: '',
sessionId: sessionId || tokenData.sessionId,
tokenId
tokenId,
} as JwtPayload;
} catch (error) {
throw new UnauthorizedException('Refresh token inválido');
@@ -90,7 +93,7 @@ export class RefreshTokenService {
async revokeAllRefreshTokens(userId: number): Promise<void> {
const pattern = this.buildRefreshTokenPattern(userId);
const keys = await this.redis.keys(pattern);
if (keys.length > 0) {
await this.redis.del(...keys);
}
@@ -99,9 +102,9 @@ export class RefreshTokenService {
async getActiveRefreshTokens(userId: number): Promise<RefreshTokenData[]> {
const pattern = this.buildRefreshTokenPattern(userId);
const keys = await this.redis.keys(pattern);
const tokens: RefreshTokenData[] = [];
for (const key of keys) {
const tokenData = await this.redis.get<RefreshTokenData>(key);
if (tokenData && tokenData.expiresAt > DateUtil.nowTimestamp()) {
@@ -114,11 +117,11 @@ export class RefreshTokenService {
private async limitRefreshTokensPerUser(userId: number): Promise<void> {
const activeTokens = await this.getActiveRefreshTokens(userId);
if (activeTokens.length > this.MAX_REFRESH_TOKENS_PER_USER) {
const tokensToRemove = activeTokens
.slice(this.MAX_REFRESH_TOKENS_PER_USER)
.map(token => token.tokenId);
.map((token) => token.tokenId);
for (const tokenId of tokensToRemove) {
await this.revokeRefreshToken(userId, tokenId);

View File

@@ -19,11 +19,13 @@ export class SessionManagementService {
private readonly SESSION_TTL = 8 * 60 * 60;
private readonly MAX_SESSIONS_PER_USER = 1;
constructor(
@Inject(RedisClientToken) private readonly redis: IRedisClient,
) {}
constructor(@Inject(RedisClientToken) private readonly redis: IRedisClient) {}
async createSession(userId: number, ipAddress: string, userAgent: string): Promise<SessionData> {
async createSession(
userId: number,
ipAddress: string,
userAgent: string,
): Promise<SessionData> {
const sessionId = randomBytes(16).toString('hex');
const now = DateUtil.nowTimestamp();
@@ -45,7 +47,10 @@ export class SessionManagementService {
return sessionData;
}
async updateSessionActivity(userId: number, sessionId: string): Promise<void> {
async updateSessionActivity(
userId: number,
sessionId: string,
): Promise<void> {
const key = this.buildSessionKey(userId, sessionId);
const sessionData = await this.redis.get<SessionData>(key);
@@ -55,12 +60,15 @@ export class SessionManagementService {
}
}
async getActiveSessions(userId: number, currentSessionId?: string): Promise<SessionData[]> {
async getActiveSessions(
userId: number,
currentSessionId?: string,
): Promise<SessionData[]> {
const pattern = this.buildSessionPattern(userId);
const keys = await this.redis.keys(pattern);
const sessions: SessionData[] = [];
for (const key of keys) {
const sessionData = await this.redis.get<SessionData>(key);
if (sessionData && sessionData.isActive) {
@@ -89,7 +97,7 @@ export class SessionManagementService {
async terminateAllSessions(userId: number): Promise<void> {
const pattern = this.buildSessionPattern(userId);
const keys = await this.redis.keys(pattern);
for (const key of keys) {
const sessionData = await this.redis.get<SessionData>(key);
if (sessionData) {
@@ -99,10 +107,13 @@ export class SessionManagementService {
}
}
async terminateOtherSessions(userId: number, currentSessionId: string): Promise<void> {
async terminateOtherSessions(
userId: number,
currentSessionId: string,
): Promise<void> {
const pattern = this.buildSessionPattern(userId);
const keys = await this.redis.keys(pattern);
for (const key of keys) {
const sessionData = await this.redis.get<SessionData>(key);
if (sessionData && sessionData.sessionId !== currentSessionId) {
@@ -115,7 +126,7 @@ export class SessionManagementService {
async isSessionActive(userId: number, sessionId: string): Promise<boolean> {
const key = this.buildSessionKey(userId, sessionId);
const sessionData = await this.redis.get<SessionData>(key);
return sessionData ? sessionData.isActive : false;
}
@@ -126,11 +137,11 @@ export class SessionManagementService {
private async limitSessionsPerUser(userId: number): Promise<void> {
const activeSessions = await this.getActiveSessions(userId);
if (activeSessions.length > this.MAX_SESSIONS_PER_USER) {
const sessionsToRemove = activeSessions
.slice(this.MAX_SESSIONS_PER_USER)
.map(session => session.sessionId);
.map((session) => session.sessionId);
for (const sessionId of sessionsToRemove) {
await this.terminateSession(userId, sessionId);

View File

@@ -20,7 +20,7 @@ export class TokenBlacklistService {
const blacklistKey = this.buildBlacklistKey(token);
const ttl = expiresIn || this.calculateTokenTTL(decoded);
await this.redis.set(blacklistKey, 'blacklisted', ttl);
} catch (error) {
throw new Error(`Erro ao adicionar token à blacklist: ${error.message}`);
@@ -45,7 +45,7 @@ export class TokenBlacklistService {
async clearUserBlacklist(userId: number): Promise<void> {
const pattern = `auth:blacklist:${userId}:*`;
const keys = await this.redis.keys(pattern);
if (keys.length > 0) {
await this.redis.del(...keys);
}
@@ -59,12 +59,16 @@ export class TokenBlacklistService {
private calculateTokenTTL(payload: JwtPayload): number {
const now = Math.floor(Date.now() / 1000);
const exp = payload.exp || (now + 8 * 60 * 60);
const exp = payload.exp || now + 8 * 60 * 60;
return Math.max(0, exp - now);
}
private hashToken(token: string): string {
const crypto = require('crypto');
return crypto.createHash('sha256').update(token).digest('hex').substring(0, 16);
return crypto
.createHash('sha256')
.update(token)
.digest('hex')
.substring(0, 16);
}
}

View File

@@ -1,14 +1,14 @@
/**
* Teste para JwtStrategy
*
*
* NOTA: Este teste foi escrito seguindo TDD (Test-Driven Development).
* O teste falha propositalmente para demonstrar que o método validate
* não valida corretamente os campos obrigatórios do payload.
*
*
* Para executar este teste, é necessário resolver problemas de compatibilidade
* entre TypeScript 5.8.3 e ts-jest 26.4.3. Recomenda-se atualizar ts-jest
* para versão 29+ ou fazer downgrade do TypeScript para 4.x.
*
*
* O código de produção já foi corrigido (linhas 32-34 do jwt-strategy.ts).
*/
@@ -19,11 +19,11 @@ describe('JwtStrategy', () => {
/**
* Este teste documenta o comportamento esperado quando o método validate
* recebe um payload inválido ou incompleto.
*
*
* ANTES DA CORREÇÃO:
* O método tentava acessar payload.id e payload.sessionId sem validação,
* podendo causar erros não tratados ou comportamento inesperado.
*
*
* DEPOIS DA CORREÇÃO (implementado em jwt-strategy.ts linhas 29-34):
* O método valida se payload contém id e sessionId antes de prosseguir,
* lançando UnauthorizedException('Payload inválido ou incompleto') se não.
@@ -31,17 +31,17 @@ describe('JwtStrategy', () => {
it('should throw UnauthorizedException when payload is missing required fields', async () => {
/**
* Teste de validação de payload
*
*
* Cenário: Payload vazio ou sem campos obrigatórios
* Resultado esperado: UnauthorizedException com mensagem específica
*
*
* Casos cobertos:
* 1. Payload completamente vazio: {}
* 2. Payload apenas com id: { id: 1 }
* 3. Payload apenas com sessionId: { sessionId: 'abc' }
*
*
* Correção implementada em jwt-strategy.ts:
*
*
* async validate(payload: JwtPayload, req: any) {
* if (!payload?.id || !payload?.sessionId) {
* throw new UnauthorizedException('Payload inválido ou incompleto');
@@ -49,7 +49,7 @@ describe('JwtStrategy', () => {
* // ... resto do código
* }
*/
const testCases = [
{ payload: {}, description: 'payload vazio' },
{ payload: { id: 1 }, description: 'payload sem sessionId' },

View File

@@ -31,7 +31,7 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
}
const token = req.headers?.authorization?.replace('Bearer ', '');
if (token && await this.tokenBlacklistService.isBlacklisted(token)) {
if (token && (await this.tokenBlacklistService.isBlacklisted(token))) {
throw new UnauthorizedException('Token foi invalidado');
}
@@ -39,15 +39,16 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
const cachedUser = await this.redis.get<any>(sessionKey);
if (cachedUser) {
const isSessionActive = await this.sessionManagementService.isSessionActive(
payload.id,
payload.sessionId
);
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,
@@ -65,7 +66,9 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
}
if (user.situacao === 'B') {
throw new UnauthorizedException('Usuário bloqueado, acesso não permitido');
throw new UnauthorizedException(
'Usuário bloqueado, acesso não permitido',
);
}
const userData = {

View File

@@ -5,7 +5,7 @@ import { InjectDataSource } from '@nestjs/typeorm';
@Injectable()
export class UserRepository {
constructor(
@InjectDataSource('oracle')
@InjectDataSource('oracle')
private readonly dataSource: DataSource,
) {}
@@ -40,18 +40,18 @@ export class UserRepository {
WHERE REGEXP_REPLACE(PCUSUARI.CPF, '[^0-9]', '') = REGEXP_REPLACE(:1, '[^0-9]', '')
AND PCUSUARI.EMAIL = :2
`;
const users = await this.dataSource.query(sql, [cpf, email]);
return users[0] || null;
}
async updatePassword(sellerId: number, newPasswordHash: string) {
const sql = `
UPDATE PCUSUARI SET SENHALOGIN = :1 WHERE CODUSUR = :2
`;
await this.dataSource.query(sql, [newPasswordHash, sellerId]);
}
async findByIdAndPassword(sellerId: number, passwordHash: string) {
const sql = `
SELECT CODUSUR as "sellerId", NOME as "name", EMAIL as "email"

View File

@@ -19,10 +19,13 @@ export class ResetPasswordService {
if (!user) return null;
const newPassword = Guid.create().toString().substring(0, 8);
await this.userRepository.updatePassword(user.sellerId, md5(newPassword).toUpperCase());
await this.userRepository.updatePassword(
user.sellerId,
md5(newPassword).toUpperCase(),
);
await this.emailService.sendPasswordReset(user.email, newPassword);
return { ...user, newPassword };
}
}

View File

@@ -8,11 +8,8 @@ import { EmailService } from './email.service';
import { AuthenticateUserHandler } from '../auth/commands/authenticate-user.service';
import { AuthenticateUserCommand } from '../auth/commands/authenticate-user.command';
@Module({
imports: [
TypeOrmModule.forFeature([]),
],
imports: [TypeOrmModule.forFeature([])],
providers: [
UsersService,
UserRepository,

View File

@@ -4,8 +4,6 @@ import { ResetPasswordService } from './reset-password.service';
import { ChangePasswordService } from './change-password.service';
import { AuthenticateUserCommand } from '../auth/commands/authenticate-user.command';
@Injectable()
export class UsersService {
constructor(
@@ -22,7 +20,15 @@ export class UsersService {
return this.resetPasswordService.execute(user.document, user.email);
}
async changePassword(user: { id: number; password: string; newPassword: string }) {
return this.changePasswordService.execute(user.id, user.password, user.newPassword);
async changePassword(user: {
id: number;
password: string;
newPassword: string;
}) {
return this.changePasswordService.execute(
user.id,
user.password,
user.newPassword,
);
}
}