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<String>`, 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.
This commit is contained in:
@@ -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:
|
Das Auth-Modul ist in zwei spezialisierte Komponenten aufgeteilt, um eine klare Trennung der Verantwortlichkeiten zu gewährleisten:
|
||||||
|
|
||||||
|
|
||||||
infrastructure/auth/
|
infrastructure/auth/
|
||||||
├── auth-client/ # Wiederverwendbare Bibliothek für die JWT-Validierung
|
├── auth-client/ # Wiederverwendbare Bibliothek für die JWT-Validierung
|
||||||
└── auth-server/ # Eigenständiger Service für Benutzerverwaltung & Token-Austausch
|
└── auth-server/ # Eigenständiger Service für Benutzerverwaltung & Token-Austausch
|
||||||
|
|
||||||
|
|
||||||
### `auth-client`
|
### `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.
|
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:**
|
**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.
|
* **JWT-Management:** Stellt einen `JwtService` zur Erstellung und Validierung von JSON Web Tokens bereit.
|
||||||
* **Rechte-Extraktion:** Extrahiert die Rollen und Berechtigungen des Benutzers aus dem validierten Token.
|
* **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.
|
||||||
* **Security Context:** Befüllt den `SecurityContextHolder` von Spring, sodass in den Controllern einfach auf den authentifizierten Benutzer zugegriffen werden kann (z.B. mit `@AuthenticationPrincipal`).
|
* **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.
|
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.
|
Dies ist ein **eigenständiger Spring Boot Microservice**, der als Brücke zwischen dem Meldestelle-System und Keycloak agiert.
|
||||||
|
|
||||||
**Hauptaufgaben:**
|
**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.
|
* **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:** Kann Endpunkte für den Austausch oder die Erneuerung von Tokens bereitstellen (Token Exchange).
|
* **Token-Endpunkte:** Ist verantwortlich für das Ausstellen von Tokens nach einer erfolgreichen Authentifizierung.
|
||||||
* **Zentraler Login-Punkt (Optional):** Kann als zentraler Punkt für Login-Weiterleitungen im OAuth2/OIDC-Flow dienen.
|
* **Implementierung der `AuthenticationService`-Schnittstelle:** Enthält die konkrete Logik, die gegen Keycloak prüft, ob ein Benutzername und ein Passwort korrekt sind.
|
||||||
|
|
||||||
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`.
|
|
||||||
|
|
||||||
## Zusammenspiel im System
|
## Zusammenspiel im System
|
||||||
|
|
||||||
1. Ein **Benutzer** meldet sich über eine Client-Anwendung an und erhält ein JWT von **Keycloak**.
|
1. Ein **Benutzer** meldet sich über eine Client-Anwendung am **`auth-server`** an.
|
||||||
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.
|
2. Der **`auth-server`** validiert die Anmeldedaten gegen **Keycloak**.
|
||||||
3. Der **Microservice**, der `auth-client` als Abhängigkeit hat, validiert das Token automatisch.
|
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. Wenn der Microservice Benutzerdaten ändern muss, ruft er nicht direkt Keycloak auf, sondern die sichere REST-API des **`auth-server`**.
|
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.
|
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
|
||||||
|
|||||||
+3
-2
@@ -1,5 +1,6 @@
|
|||||||
package at.mocode.infrastructure.auth.client
|
package at.mocode.infrastructure.auth.client
|
||||||
|
|
||||||
|
import at.mocode.infrastructure.auth.client.model.BerechtigungE
|
||||||
import com.benasher44.uuid.Uuid
|
import com.benasher44.uuid.Uuid
|
||||||
import java.time.LocalDateTime
|
import java.time.LocalDateTime
|
||||||
|
|
||||||
@@ -58,7 +59,7 @@ interface AuthenticationService {
|
|||||||
*/
|
*/
|
||||||
sealed class PasswordChangeResult {
|
sealed class PasswordChangeResult {
|
||||||
/**
|
/**
|
||||||
* Password change was successful.
|
* The password change was successful.
|
||||||
*/
|
*/
|
||||||
object Success : PasswordChangeResult()
|
object Success : PasswordChangeResult()
|
||||||
|
|
||||||
@@ -83,6 +84,6 @@ interface AuthenticationService {
|
|||||||
val personId: Uuid,
|
val personId: Uuid,
|
||||||
val username: String,
|
val username: String,
|
||||||
val email: String,
|
val email: String,
|
||||||
val permissions: List<String>
|
val permissions: List<BerechtigungE>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+21
-35
@@ -1,9 +1,11 @@
|
|||||||
package at.mocode.infrastructure.auth.client
|
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.JWT
|
||||||
import com.auth0.jwt.algorithms.Algorithm
|
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.
|
* Service for JWT token generation and validation.
|
||||||
@@ -12,16 +14,14 @@ class JwtService(
|
|||||||
private val secret: String,
|
private val secret: String,
|
||||||
private val issuer: String,
|
private val issuer: String,
|
||||||
private val audience: String,
|
private val audience: String,
|
||||||
private val expirationInMinutes: Long = 60
|
private val expiration: Duration = 60.minutes
|
||||||
) {
|
) {
|
||||||
/**
|
private val algorithm = Algorithm.HMAC512(secret)
|
||||||
* Generates a JWT token for the given user.
|
private val verifier = JWT.require(algorithm)
|
||||||
*
|
.withIssuer(issuer)
|
||||||
* @param userId The user ID
|
.withAudience(audience)
|
||||||
* @param username The username
|
.build()
|
||||||
* @param permissions The user's permissions
|
|
||||||
* @return The generated JWT token
|
|
||||||
*/
|
|
||||||
fun generateToken(
|
fun generateToken(
|
||||||
userId: String,
|
userId: String,
|
||||||
username: String,
|
username: String,
|
||||||
@@ -33,8 +33,8 @@ class JwtService(
|
|||||||
.withAudience(audience)
|
.withAudience(audience)
|
||||||
.withClaim("username", username)
|
.withClaim("username", username)
|
||||||
.withArrayClaim("permissions", permissions.map { it.name }.toTypedArray())
|
.withArrayClaim("permissions", permissions.map { it.name }.toTypedArray())
|
||||||
.withExpiresAt(Date(System.currentTimeMillis() + expirationInMinutes * 60 * 1000))
|
.withExpiresAt(Date(System.currentTimeMillis() + expiration.inWholeMilliseconds))
|
||||||
.sign(Algorithm.HMAC512(secret))
|
.sign(algorithm)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -45,13 +45,9 @@ class JwtService(
|
|||||||
*/
|
*/
|
||||||
fun validateToken(token: String): Boolean {
|
fun validateToken(token: String): Boolean {
|
||||||
return try {
|
return try {
|
||||||
JWT.require(Algorithm.HMAC512(secret))
|
verifier.verify(token)
|
||||||
.withIssuer(issuer)
|
|
||||||
.withAudience(audience)
|
|
||||||
.build()
|
|
||||||
.verify(token)
|
|
||||||
true
|
true
|
||||||
} catch (e: Exception) {
|
} catch (_: Exception) {
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -64,13 +60,8 @@ class JwtService(
|
|||||||
*/
|
*/
|
||||||
fun getUserIdFromToken(token: String): String? {
|
fun getUserIdFromToken(token: String): String? {
|
||||||
return try {
|
return try {
|
||||||
JWT.require(Algorithm.HMAC512(secret))
|
verifier.verify(token).subject
|
||||||
.withIssuer(issuer)
|
} catch (_: Exception) {
|
||||||
.withAudience(audience)
|
|
||||||
.build()
|
|
||||||
.verify(token)
|
|
||||||
.subject
|
|
||||||
} catch (e: Exception) {
|
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -83,21 +74,16 @@ class JwtService(
|
|||||||
*/
|
*/
|
||||||
fun getPermissionsFromToken(token: String): List<BerechtigungE> {
|
fun getPermissionsFromToken(token: String): List<BerechtigungE> {
|
||||||
return try {
|
return try {
|
||||||
val decodedJWT = JWT.require(Algorithm.HMAC512(secret))
|
val decodedJWT = verifier.verify(token)
|
||||||
.withIssuer(issuer)
|
|
||||||
.withAudience(audience)
|
|
||||||
.build()
|
|
||||||
.verify(token)
|
|
||||||
|
|
||||||
val permissionStrings = decodedJWT.getClaim("permissions").asArray(String::class.java)
|
val permissionStrings = decodedJWT.getClaim("permissions").asArray(String::class.java)
|
||||||
permissionStrings.mapNotNull {
|
permissionStrings?.mapNotNull {
|
||||||
try {
|
try {
|
||||||
BerechtigungE.valueOf(it)
|
BerechtigungE.valueOf(it)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
}
|
} ?: emptyList()
|
||||||
} catch (e: Exception) {
|
} catch (_: Exception) {
|
||||||
emptyList()
|
emptyList()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+49
@@ -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
|
||||||
|
}
|
||||||
+81
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user