From a77b0c1221becf8ee0a4f264b03695727403037c Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 17 May 2026 19:21:46 +0200 Subject: [PATCH] =?UTF-8?q?feat(auth):=20AuthService=20=E2=80=94=20login/l?= =?UTF-8?q?ogout=20with=20audit=20logging=20and=20timing-safe=20credential?= =?UTF-8?q?=20rejection?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .../familienarchiv/auth/AuthService.java | 69 ++++++++++ .../exception/DomainException.java | 5 + .../familienarchiv/auth/AuthServiceTest.java | 129 ++++++++++++++++++ 3 files changed, 203 insertions(+) create mode 100644 backend/src/main/java/org/raddatz/familienarchiv/auth/AuthService.java create mode 100644 backend/src/test/java/org/raddatz/familienarchiv/auth/AuthServiceTest.java diff --git a/backend/src/main/java/org/raddatz/familienarchiv/auth/AuthService.java b/backend/src/main/java/org/raddatz/familienarchiv/auth/AuthService.java new file mode 100644 index 00000000..c66b6418 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/auth/AuthService.java @@ -0,0 +1,69 @@ +package org.raddatz.familienarchiv.auth; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.raddatz.familienarchiv.audit.AuditKind; +import org.raddatz.familienarchiv.audit.AuditService; +import org.raddatz.familienarchiv.exception.DomainException; +import org.raddatz.familienarchiv.user.AppUser; +import org.raddatz.familienarchiv.user.UserService; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; +import org.springframework.stereotype.Service; + +import java.util.Map; +import java.util.UUID; + +@Service +@RequiredArgsConstructor +@Slf4j +public class AuthService { + + private final AuthenticationManager authenticationManager; + private final UserService userService; + private final AuditService auditService; + + /** + * Validates credentials and returns the authenticated user plus the Spring Security + * Authentication object. The caller is responsible for persisting the Authentication + * to the session via SecurityContextRepository. + */ + public LoginResult login(String email, String password, String ip, String ua) { + try { + Authentication auth = authenticationManager.authenticate( + new UsernamePasswordAuthenticationToken(email, password)); + + AppUser user = userService.findByEmail(email); + auditService.log(AuditKind.LOGIN_SUCCESS, user.getId(), null, Map.of( + "userId", user.getId().toString(), + "ip", ip, + "ua", truncateUa(ua))); + return new LoginResult(user, auth); + } catch (AuthenticationException ex) { + // Audit login failure — intentionally does NOT log the attempted password. + // DaoAuthenticationProvider already runs a dummy BCrypt on unknown users to + // equalise timing between "user not found" and "wrong password" paths. + auditService.log(AuditKind.LOGIN_FAILED, null, null, Map.of( + "email", email, + "ip", ip, + "ua", truncateUa(ua))); + throw DomainException.invalidCredentials(); + } + } + + public void logout(UUID userId, String ip, String ua) { + auditService.log(AuditKind.LOGOUT, userId, null, Map.of( + "userId", userId.toString(), + "ip", ip, + "ua", truncateUa(ua))); + } + + private static String truncateUa(String ua) { + if (ua == null) return ""; + return ua.length() > 200 ? ua.substring(0, 200) : ua; + } + + public record LoginResult(AppUser user, Authentication authentication) {} +} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/exception/DomainException.java b/backend/src/main/java/org/raddatz/familienarchiv/exception/DomainException.java index f82768c6..b911059e 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/exception/DomainException.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/exception/DomainException.java @@ -39,6 +39,11 @@ public class DomainException extends RuntimeException { return new DomainException(ErrorCode.UNAUTHORIZED, HttpStatus.UNAUTHORIZED, message); } + public static DomainException invalidCredentials() { + return new DomainException(ErrorCode.INVALID_CREDENTIALS, HttpStatus.UNAUTHORIZED, + "Invalid email or password"); + } + public static DomainException conflict(ErrorCode code, String message) { return new DomainException(code, HttpStatus.CONFLICT, message); } diff --git a/backend/src/test/java/org/raddatz/familienarchiv/auth/AuthServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/auth/AuthServiceTest.java new file mode 100644 index 00000000..f394c056 --- /dev/null +++ b/backend/src/test/java/org/raddatz/familienarchiv/auth/AuthServiceTest.java @@ -0,0 +1,129 @@ +package org.raddatz.familienarchiv.auth; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.raddatz.familienarchiv.audit.AuditKind; +import org.raddatz.familienarchiv.audit.AuditService; +import org.raddatz.familienarchiv.exception.DomainException; +import org.raddatz.familienarchiv.exception.ErrorCode; +import org.raddatz.familienarchiv.user.AppUser; +import org.raddatz.familienarchiv.user.UserService; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; + +import java.util.Map; +import java.util.Set; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class AuthServiceTest { + + @Mock AuthenticationManager authenticationManager; + @Mock UserService userService; + @Mock AuditService auditService; + @InjectMocks AuthService authService; + + private static final String IP = "127.0.0.1"; + private static final String UA = "Mozilla/5.0 (Test)"; + + @Test + void login_returns_user_on_valid_credentials() { + UUID userId = UUID.randomUUID(); + AppUser user = AppUser.builder().id(userId).email("user@test.de").build(); + Authentication auth = new UsernamePasswordAuthenticationToken("user@test.de", null, Set.of()); + when(authenticationManager.authenticate(any())).thenReturn(auth); + when(userService.findByEmail("user@test.de")).thenReturn(user); + + AuthService.LoginResult result = authService.login("user@test.de", "pass123", IP, UA); + + assertThat(result.user()).isEqualTo(user); + assertThat(result.authentication()).isEqualTo(auth); + } + + @Test + void login_fires_LOGIN_SUCCESS_audit_on_valid_credentials() { + UUID userId = UUID.randomUUID(); + AppUser user = AppUser.builder().id(userId).email("user@test.de").build(); + Authentication auth = new UsernamePasswordAuthenticationToken("user@test.de", null, Set.of()); + when(authenticationManager.authenticate(any())).thenReturn(auth); + when(userService.findByEmail("user@test.de")).thenReturn(user); + + authService.login("user@test.de", "pass123", IP, UA); + + verify(auditService).log( + eq(AuditKind.LOGIN_SUCCESS), + eq(userId), + isNull(), + argThat(payload -> userId.toString().equals(payload.get("userId").toString()) + && IP.equals(payload.get("ip")) + && !payload.containsKey("password")) + ); + } + + @Test + void login_throws_INVALID_CREDENTIALS_on_bad_password() { + when(authenticationManager.authenticate(any())).thenThrow(new BadCredentialsException("bad")); + + assertThatThrownBy(() -> authService.login("user@test.de", "wrong", IP, UA)) + .isInstanceOf(DomainException.class) + .satisfies(ex -> assertThat(((DomainException) ex).getCode()) + .isEqualTo(ErrorCode.INVALID_CREDENTIALS)); + } + + @Test + void login_fires_LOGIN_FAILED_audit_on_bad_credentials_without_password_in_payload() { + when(authenticationManager.authenticate(any())).thenThrow(new BadCredentialsException("bad")); + + assertThatThrownBy(() -> authService.login("user@test.de", "wrong", IP, UA)) + .isInstanceOf(DomainException.class); + + verify(auditService).log( + eq(AuditKind.LOGIN_FAILED), + isNull(), + isNull(), + argThat(payload -> "user@test.de".equals(payload.get("email")) + && IP.equals(payload.get("ip")) + && !payload.containsKey("password") + && !payload.containsKey("pwd") + && !payload.containsKey("passwordAttempt")) + ); + } + + @Test + void login_treats_unknown_user_identically_to_bad_password() { + when(authenticationManager.authenticate(any())) + .thenThrow(new BadCredentialsException("unknown user hidden as bad creds")); + + assertThatThrownBy(() -> authService.login("unknown@test.de", "any", IP, UA)) + .isInstanceOf(DomainException.class) + .satisfies(ex -> assertThat(((DomainException) ex).getCode()) + .isEqualTo(ErrorCode.INVALID_CREDENTIALS)); + + verify(auditService).log(eq(AuditKind.LOGIN_FAILED), isNull(), isNull(), anyMap()); + } + + @Test + void logout_fires_LOGOUT_audit() { + UUID userId = UUID.randomUUID(); + + authService.logout(userId, IP, UA); + + verify(auditService).log( + eq(AuditKind.LOGOUT), + eq(userId), + isNull(), + argThat(payload -> userId.toString().equals(payload.get("userId").toString()) + && IP.equals(payload.get("ip"))) + ); + } +}