feat(auth): AuthService — login/logout with audit logging and timing-safe credential rejection
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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) {}
|
||||||
|
}
|
||||||
@@ -39,6 +39,11 @@ public class DomainException extends RuntimeException {
|
|||||||
return new DomainException(ErrorCode.UNAUTHORIZED, HttpStatus.UNAUTHORIZED, message);
|
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) {
|
public static DomainException conflict(ErrorCode code, String message) {
|
||||||
return new DomainException(code, HttpStatus.CONFLICT, message);
|
return new DomainException(code, HttpStatus.CONFLICT, message);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user