From 770f59500da8ce12883d26e3f96c6a361be2e5c7 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Mon, 30 Mar 2026 10:26:37 +0200 Subject: [PATCH] feat: add login with password verification and audit logging Co-Authored-By: Claude Opus 4.6 (1M context) --- .../cameleer/saas/auth/AuthService.java | 27 +++++++ .../cameleer/saas/auth/dto/LoginRequest.java | 10 +++ .../cameleer/saas/auth/AuthServiceTest.java | 73 +++++++++++++++++++ 3 files changed, 110 insertions(+) create mode 100644 src/main/java/net/siegeln/cameleer/saas/auth/dto/LoginRequest.java diff --git a/src/main/java/net/siegeln/cameleer/saas/auth/AuthService.java b/src/main/java/net/siegeln/cameleer/saas/auth/AuthService.java index c0cff5d..1df84b7 100644 --- a/src/main/java/net/siegeln/cameleer/saas/auth/AuthService.java +++ b/src/main/java/net/siegeln/cameleer/saas/auth/AuthService.java @@ -3,6 +3,7 @@ package net.siegeln.cameleer.saas.auth; import net.siegeln.cameleer.saas.audit.AuditAction; import net.siegeln.cameleer.saas.audit.AuditService; import net.siegeln.cameleer.saas.auth.dto.AuthResponse; +import net.siegeln.cameleer.saas.auth.dto.LoginRequest; import net.siegeln.cameleer.saas.auth.dto.RegisterRequest; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; @@ -52,4 +53,30 @@ public class AuthService { return new AuthResponse(token, saved.getEmail(), saved.getName()); } + + public AuthResponse login(LoginRequest request, String sourceIp) { + var user = userRepository.findByEmail(request.email()) + .orElseThrow(() -> new IllegalArgumentException("Invalid credentials")); + + if (!passwordEncoder.matches(request.password(), user.getPassword())) { + auditService.log( + user.getId(), user.getEmail(), null, + AuditAction.AUTH_LOGIN_FAILED, null, + null, sourceIp, + "FAILURE", null + ); + throw new IllegalArgumentException("Invalid credentials"); + } + + var token = jwtService.generateToken(user); + + auditService.log( + user.getId(), user.getEmail(), null, + AuditAction.AUTH_LOGIN, null, + null, sourceIp, + "SUCCESS", null + ); + + return new AuthResponse(token, user.getEmail(), user.getName()); + } } diff --git a/src/main/java/net/siegeln/cameleer/saas/auth/dto/LoginRequest.java b/src/main/java/net/siegeln/cameleer/saas/auth/dto/LoginRequest.java new file mode 100644 index 0000000..8602f8f --- /dev/null +++ b/src/main/java/net/siegeln/cameleer/saas/auth/dto/LoginRequest.java @@ -0,0 +1,10 @@ +package net.siegeln.cameleer.saas.auth.dto; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; + +public record LoginRequest( + @NotBlank @Email String email, + @NotBlank String password +) { +} diff --git a/src/test/java/net/siegeln/cameleer/saas/auth/AuthServiceTest.java b/src/test/java/net/siegeln/cameleer/saas/auth/AuthServiceTest.java index 2085017..3eb8282 100644 --- a/src/test/java/net/siegeln/cameleer/saas/auth/AuthServiceTest.java +++ b/src/test/java/net/siegeln/cameleer/saas/auth/AuthServiceTest.java @@ -2,6 +2,7 @@ package net.siegeln.cameleer.saas.auth; import net.siegeln.cameleer.saas.audit.AuditAction; import net.siegeln.cameleer.saas.audit.AuditService; +import net.siegeln.cameleer.saas.auth.dto.LoginRequest; import net.siegeln.cameleer.saas.auth.dto.RegisterRequest; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -12,6 +13,7 @@ import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.security.crypto.password.PasswordEncoder; import java.util.Optional; +import java.util.UUID; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; @@ -95,4 +97,75 @@ class AuthServiceTest { verify(userRepository, never()).save(any()); verify(auditService, never()).log(any(), any(), any(), any(), any(), any(), any(), any(), any()); } + + @Test + void login_returnsTokenForValidCredentials() { + var request = new LoginRequest("user@example.com", "password123"); + var user = createUserWithId("user@example.com", "encoded-password"); + + when(userRepository.findByEmail("user@example.com")).thenReturn(Optional.of(user)); + when(passwordEncoder.matches("password123", "encoded-password")).thenReturn(true); + when(jwtService.generateToken(user)).thenReturn("login-jwt-token"); + + var response = authService.login(request, "192.168.1.1"); + + assertNotNull(response); + assertEquals("login-jwt-token", response.token()); + assertEquals("user@example.com", response.email()); + + verify(auditService).log( + any(), eq("user@example.com"), eq(null), + eq(AuditAction.AUTH_LOGIN), eq(null), + eq(null), eq("192.168.1.1"), + eq("SUCCESS"), eq(null) + ); + } + + @Test + void login_rejectsInvalidPassword() { + var request = new LoginRequest("user@example.com", "wrong-password"); + var user = createUserWithId("user@example.com", "encoded-password"); + + when(userRepository.findByEmail("user@example.com")).thenReturn(Optional.of(user)); + when(passwordEncoder.matches("wrong-password", "encoded-password")).thenReturn(false); + + assertThrows(IllegalArgumentException.class, + () -> authService.login(request, "192.168.1.1")); + + // Verify AUTH_LOGIN_FAILED audit was logged + verify(auditService).log( + any(), eq("user@example.com"), eq(null), + eq(AuditAction.AUTH_LOGIN_FAILED), eq(null), + eq(null), eq("192.168.1.1"), + eq("FAILURE"), eq(null) + ); + } + + @Test + void login_rejectsUnknownEmail() { + var request = new LoginRequest("unknown@example.com", "password123"); + + when(userRepository.findByEmail("unknown@example.com")).thenReturn(Optional.empty()); + + var exception = assertThrows(IllegalArgumentException.class, + () -> authService.login(request, "192.168.1.1")); + + assertEquals("Invalid credentials", exception.getMessage()); + verify(auditService, never()).log(any(), any(), any(), any(), any(), any(), any(), any(), any()); + } + + private UserEntity createUserWithId(String email, String password) { + var user = new UserEntity(); + user.setEmail(email); + user.setName("Test User"); + user.setPassword(password); + try { + var idField = UserEntity.class.getDeclaredField("id"); + idField.setAccessible(true); + idField.set(user, UUID.randomUUID()); + } catch (Exception e) { + throw new RuntimeException(e); + } + return user; + } }