From f9927066a2d31d090ad61917d54a92528d483e90 Mon Sep 17 00:00:00 2001 From: StefanMoCoAt Date: Sat, 9 Aug 2025 19:35:53 +0200 Subject: [PATCH] refactor(infra-auth): Relocate auth models and add JWT tests This commit resolves the build failures caused by the refactoring of the `core` module and significantly improves the quality of the `auth-client` module. ### Architectural Refinements - **Relocated Auth Enums:** The `RolleE` and `BerechtigungE` enums have been moved from the `core` module to their correct logical home within `:infrastructure:auth:auth-client`. The `auth-client` is now the single source of truth for authorization models, making it a self-contained and more coherent module. - **Improved Type Safety:** The `AuthenticationService` interface and its DTOs now use the type-safe `BerechtigungE` enum instead of raw `List`, improving consistency and reducing the risk of runtime errors. - The `JwtService` now uses `kotlin.time.Duration` for token expiration, aligning it with project-wide best practices. ### Testing Enhancements - **Added JWT Service Tests:** Introduced a comprehensive `JwtServiceTest` suite. - The tests cover token generation, validation (including successful, invalid secret, and expired token scenarios), and the correct extraction of claims like user ID and permissions. - This ensures the reliability and security of our core authentication mechanism. --- infrastructure/auth/README-INFRA-AUTH.md | 31 ++++--- .../auth/client/AuthenticationService.kt | 5 +- .../infrastructure/auth/client/JwtService.kt | 56 +++++-------- .../auth/client/model/AuthEnums.kt | 49 +++++++++++ .../auth/client/JwtServiceTest.kt | 81 +++++++++++++++++++ 5 files changed, 168 insertions(+), 54 deletions(-) create mode 100644 infrastructure/auth/auth-client/src/main/kotlin/at/mocode/infrastructure/auth/client/model/AuthEnums.kt create mode 100644 infrastructure/auth/auth-client/src/test/kotlin/at/mocode/infrastructure/auth/client/JwtServiceTest.kt diff --git a/infrastructure/auth/README-INFRA-AUTH.md b/infrastructure/auth/README-INFRA-AUTH.md index 77d85819..8f2a62a4 100644 --- a/infrastructure/auth/README-INFRA-AUTH.md +++ b/infrastructure/auth/README-INFRA-AUTH.md @@ -10,10 +10,9 @@ Als Identity Provider wird **Keycloak** verwendet. Dieses Modul kapselt die gesa Das Auth-Modul ist in zwei spezialisierte Komponenten aufgeteilt, um eine klare Trennung der Verantwortlichkeiten zu gewährleisten: - infrastructure/auth/ -├── auth-client/ # Wiederverwendbare Bibliothek für die JWT-Validierung -└── auth-server/ # Eigenständiger Service für Benutzerverwaltung & Token-Austausch +├── auth-client/ # Wiederverwendbare Bibliothek für die JWT-Validierung +└── auth-server/ # Eigenständiger Service für Benutzerverwaltung & Token-Austausch ### `auth-client` @@ -21,9 +20,9 @@ infrastructure/auth/ Dieses Modul ist eine **wiederverwendbare Bibliothek** und kein eigenständiger Service. Es enthält die gesamte Logik, die andere Microservices (wie `masterdata-service`, `members-service` etc.) benötigen, um ihre Endpunkte abzusichern. **Hauptaufgaben:** -* **JWT-Validierung:** Stellt Spring Security Konfigurationen bereit, um eingehende JWTs (ausgestellt von Keycloak) zu validieren. Es prüft die Signatur, den Aussteller (Issuer) und die Gültigkeitsdauer des Tokens. -* **Rechte-Extraktion:** Extrahiert die Rollen und Berechtigungen des Benutzers aus dem validierten Token. -* **Security Context:** Befüllt den `SecurityContextHolder` von Spring, sodass in den Controllern einfach auf den authentifizierten Benutzer zugegriffen werden kann (z.B. mit `@AuthenticationPrincipal`). +* **JWT-Management:** Stellt einen `JwtService` zur Erstellung und Validierung von JSON Web Tokens bereit. +* **Modell-Definition:** Definiert die **Quelle der Wahrheit** für sicherheitsrelevante Konzepte wie `RolleE` und `BerechtigungE` als typsichere Kotlin-Enums. Dies stellt sicher, dass alle Services dieselbe "Sprache" für Berechtigungen sprechen. +* **Schnittstellen:** Bietet saubere Schnittstellen wie `AuthenticationService` an, die von der konkreten Implementierung (z.B. Keycloak) abstrahieren. Jeder Microservice, der geschützte Endpunkte anbietet, bindet dieses Modul als Abhängigkeit ein. @@ -32,20 +31,18 @@ Jeder Microservice, der geschützte Endpunkte anbietet, bindet dieses Modul als Dies ist ein **eigenständiger Spring Boot Microservice**, der als Brücke zwischen dem Meldestelle-System und Keycloak agiert. **Hauptaufgaben:** -* **Benutzer-API:** Stellt eine REST-API zur Verfügung, um Benutzer zu verwalten (z.B. Registrierung, Profil-Updates). Diese API kommuniziert im Hintergrund über den `keycloak-admin-client` mit Keycloak. -* **Token-Endpunkte:** Kann Endpunkte für den Austausch oder die Erneuerung von Tokens bereitstellen (Token Exchange). -* **Zentraler Login-Punkt (Optional):** Kann als zentraler Punkt für Login-Weiterleitungen im OAuth2/OIDC-Flow dienen. - -Durch die Kapselung der Keycloak-spezifischen Logik im `auth-server` müssen die anderen Fach-Services nichts über die Interna der Benutzerverwaltung wissen. Sie interagieren nur mit der sauberen API des `auth-server`. +* **Benutzer-API:** Stellt eine REST-API zur Verfügung, um Benutzer zu verwalten (z.B. Registrierung). Diese API kommuniziert im Hintergrund über den `keycloak-admin-client` mit Keycloak. +* **Token-Endpunkte:** Ist verantwortlich für das Ausstellen von Tokens nach einer erfolgreichen Authentifizierung. +* **Implementierung der `AuthenticationService`-Schnittstelle:** Enthält die konkrete Logik, die gegen Keycloak prüft, ob ein Benutzername und ein Passwort korrekt sind. ## Zusammenspiel im System -1. Ein **Benutzer** meldet sich über eine Client-Anwendung an und erhält ein JWT von **Keycloak**. -2. Die **Client-Anwendung** sendet eine Anfrage an einen Microservice (z.B. `masterdata-service`) und fügt das JWT als Bearer-Token in den `Authorization`-Header ein. -3. Der **Microservice**, der `auth-client` als Abhängigkeit hat, validiert das Token automatisch. -4. Wenn der Microservice Benutzerdaten ändern muss, ruft er nicht direkt Keycloak auf, sondern die sichere REST-API des **`auth-server`**. +1. Ein **Benutzer** meldet sich über eine Client-Anwendung am **`auth-server`** an. +2. Der **`auth-server`** validiert die Anmeldedaten gegen **Keycloak**. +3. Bei Erfolg erstellt der `auth-server` mit dem `JwtService` aus dem `auth-client` ein JWT, das die Berechtigungen des Benutzers enthält, und sendet es an den Client zurück. +4. Der **Client** sendet eine Anfrage an einen anderen Microservice (z.B. `members-service`) und fügt das JWT als Bearer-Token in den Header ein. +5. Der **`members-service`**, der ebenfalls den `auth-client` als Abhängigkeit hat, nutzt den `JwtService`, um das Token zu validieren und die Berechtigungen typsicher auszulesen. Diese Architektur entkoppelt die Fach-Services von der Komplexität der Identitätsverwaltung und schafft eine robuste, zentrale Sicherheitsinfrastruktur. - --- -**Letzte Aktualisierung**: 31. Juli 2025 +**Letzte Aktualisierung**: 9. August 2025 diff --git a/infrastructure/auth/auth-client/src/main/kotlin/at/mocode/infrastructure/auth/client/AuthenticationService.kt b/infrastructure/auth/auth-client/src/main/kotlin/at/mocode/infrastructure/auth/client/AuthenticationService.kt index c4481820..7370d0ae 100644 --- a/infrastructure/auth/auth-client/src/main/kotlin/at/mocode/infrastructure/auth/client/AuthenticationService.kt +++ b/infrastructure/auth/auth-client/src/main/kotlin/at/mocode/infrastructure/auth/client/AuthenticationService.kt @@ -1,5 +1,6 @@ package at.mocode.infrastructure.auth.client +import at.mocode.infrastructure.auth.client.model.BerechtigungE import com.benasher44.uuid.Uuid import java.time.LocalDateTime @@ -58,7 +59,7 @@ interface AuthenticationService { */ sealed class PasswordChangeResult { /** - * Password change was successful. + * The password change was successful. */ object Success : PasswordChangeResult() @@ -83,6 +84,6 @@ interface AuthenticationService { val personId: Uuid, val username: String, val email: String, - val permissions: List + val permissions: List ) } diff --git a/infrastructure/auth/auth-client/src/main/kotlin/at/mocode/infrastructure/auth/client/JwtService.kt b/infrastructure/auth/auth-client/src/main/kotlin/at/mocode/infrastructure/auth/client/JwtService.kt index 2c0d7c90..4fbf1a4d 100644 --- a/infrastructure/auth/auth-client/src/main/kotlin/at/mocode/infrastructure/auth/client/JwtService.kt +++ b/infrastructure/auth/auth-client/src/main/kotlin/at/mocode/infrastructure/auth/client/JwtService.kt @@ -1,9 +1,11 @@ package at.mocode.infrastructure.auth.client -import at.mocode.core.domain.model.BerechtigungE +import at.mocode.infrastructure.auth.client.model.BerechtigungE import com.auth0.jwt.JWT import com.auth0.jwt.algorithms.Algorithm -import java.util.* +import java.util.Date +import kotlin.time.Duration +import kotlin.time.Duration.Companion.minutes /** * Service for JWT token generation and validation. @@ -12,16 +14,14 @@ class JwtService( private val secret: String, private val issuer: String, private val audience: String, - private val expirationInMinutes: Long = 60 + private val expiration: Duration = 60.minutes ) { - /** - * Generates a JWT token for the given user. - * - * @param userId The user ID - * @param username The username - * @param permissions The user's permissions - * @return The generated JWT token - */ + private val algorithm = Algorithm.HMAC512(secret) + private val verifier = JWT.require(algorithm) + .withIssuer(issuer) + .withAudience(audience) + .build() + fun generateToken( userId: String, username: String, @@ -33,8 +33,8 @@ class JwtService( .withAudience(audience) .withClaim("username", username) .withArrayClaim("permissions", permissions.map { it.name }.toTypedArray()) - .withExpiresAt(Date(System.currentTimeMillis() + expirationInMinutes * 60 * 1000)) - .sign(Algorithm.HMAC512(secret)) + .withExpiresAt(Date(System.currentTimeMillis() + expiration.inWholeMilliseconds)) + .sign(algorithm) } /** @@ -45,13 +45,9 @@ class JwtService( */ fun validateToken(token: String): Boolean { return try { - JWT.require(Algorithm.HMAC512(secret)) - .withIssuer(issuer) - .withAudience(audience) - .build() - .verify(token) + verifier.verify(token) true - } catch (e: Exception) { + } catch (_: Exception) { false } } @@ -64,13 +60,8 @@ class JwtService( */ fun getUserIdFromToken(token: String): String? { return try { - JWT.require(Algorithm.HMAC512(secret)) - .withIssuer(issuer) - .withAudience(audience) - .build() - .verify(token) - .subject - } catch (e: Exception) { + verifier.verify(token).subject + } catch (_: Exception) { null } } @@ -83,21 +74,16 @@ class JwtService( */ fun getPermissionsFromToken(token: String): List { return try { - val decodedJWT = JWT.require(Algorithm.HMAC512(secret)) - .withIssuer(issuer) - .withAudience(audience) - .build() - .verify(token) - + val decodedJWT = verifier.verify(token) val permissionStrings = decodedJWT.getClaim("permissions").asArray(String::class.java) - permissionStrings.mapNotNull { + permissionStrings?.mapNotNull { try { BerechtigungE.valueOf(it) } catch (e: Exception) { null } - } - } catch (e: Exception) { + } ?: emptyList() + } catch (_: Exception) { emptyList() } } diff --git a/infrastructure/auth/auth-client/src/main/kotlin/at/mocode/infrastructure/auth/client/model/AuthEnums.kt b/infrastructure/auth/auth-client/src/main/kotlin/at/mocode/infrastructure/auth/client/model/AuthEnums.kt new file mode 100644 index 00000000..30351e26 --- /dev/null +++ b/infrastructure/auth/auth-client/src/main/kotlin/at/mocode/infrastructure/auth/client/model/AuthEnums.kt @@ -0,0 +1,49 @@ +package at.mocode.infrastructure.auth.client.model + +import kotlinx.serialization.Serializable + +/** + * User role enumeration for member management + */ +@Serializable +enum class RolleE { + ADMIN, // System administrator + VEREINS_ADMIN, // Club administrator + FUNKTIONAER, // Official/functionary + REITER, // Rider + TRAINER, // Trainer + RICHTER, // Judge + TIERARZT, // Veterinarian + ZUSCHAUER, // Spectator + GAST // Guest +} + +/** + * Permission enumeration for access control + */ +@Serializable +enum class BerechtigungE { + // Person management + PERSON_READ, + PERSON_CREATE, + PERSON_UPDATE, + PERSON_DELETE, + + // Club management + VEREIN_READ, + VEREIN_CREATE, + VEREIN_UPDATE, + VEREIN_DELETE, + + // Event management + VERANSTALTUNG_READ, + VERANSTALTUNG_CREATE, + VERANSTALTUNG_UPDATE, + VERANSTALTUNG_DELETE, + + // Horse management + PFERD_READ, + PFERD_CREATE, + PFERD_UPDATE, + PFERD_DELETE +} diff --git a/infrastructure/auth/auth-client/src/test/kotlin/at/mocode/infrastructure/auth/client/JwtServiceTest.kt b/infrastructure/auth/auth-client/src/test/kotlin/at/mocode/infrastructure/auth/client/JwtServiceTest.kt new file mode 100644 index 00000000..44341024 --- /dev/null +++ b/infrastructure/auth/auth-client/src/test/kotlin/at/mocode/infrastructure/auth/client/JwtServiceTest.kt @@ -0,0 +1,81 @@ +package at.mocode.infrastructure.auth.client + +import at.mocode.infrastructure.auth.client.model.BerechtigungE +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import kotlin.time.Duration.Companion.seconds + +class JwtServiceTest { + + private lateinit var jwtService: JwtService + private val testSecret = "a-very-long-and-secure-test-secret-that-is-at-least-512-bits-long-for-hmac512" + private val testIssuer = "test-issuer" + private val testAudience = "test-audience" + + @BeforeEach + fun setUp() { + jwtService = JwtService( + secret = testSecret, + issuer = testIssuer, + audience = testAudience, + expiration = 60.seconds // Kurze Lebensdauer für Tests + ) + } + + @Test + fun `generateToken should create a valid JWT with correct claims`() { + // Arrange + val userId = "user-123" + val username = "testuser" + val permissions = listOf(BerechtigungE.PERSON_READ, BerechtigungE.PFERD_CREATE) + + // Act + val token = jwtService.generateToken(userId, username, permissions) + + // Assert + assertNotNull(token) + assertTrue(jwtService.validateToken(token)) + assertEquals(userId, jwtService.getUserIdFromToken(token)) + + val extractedPermissions = jwtService.getPermissionsFromToken(token) + assertEquals(2, extractedPermissions.size) + assertTrue(extractedPermissions.contains(BerechtigungE.PERSON_READ)) + assertTrue(extractedPermissions.contains(BerechtigungE.PFERD_CREATE)) + } + + @Test + fun `validateToken should return false for token with wrong secret`() { + // Arrange + val otherService = JwtService("a-different-wrong-secret", testIssuer, testAudience) + val token = otherService.generateToken("user-123", "test", emptyList()) + + // Act & Assert + assertFalse(jwtService.validateToken(token)) + } + + @Test + fun `validateToken should return false for expired token`() { + // Arrange + val expiredService = + JwtService(testSecret, testIssuer, testAudience, expiration = (-1).seconds) // läuft sofort ab + val token = expiredService.generateToken("user-123", "test", emptyList()) + + // Act & Assert + // möglicherweise ist eine kleine Verzögerung nötig, um sicherzustellen, dass die Zeitstempel unterschiedlich sind + Thread.sleep(10) + assertFalse(jwtService.validateToken(token)) + } + + @Test + fun `getPermissionsFromToken should return empty list for invalid token`() { + // Arrange + val invalidToken = "this.is.not.a.valid.token" + + // Act + val permissions = jwtService.getPermissionsFromToken(invalidToken) + + // Assert + assertTrue(permissions.isEmpty()) + } +}