From afd109efcc955fd9ef32438c0de2b63d30b2e960 Mon Sep 17 00:00:00 2001 From: StefanMoCoAt Date: Sat, 6 Dec 2025 22:32:12 +0100 Subject: [PATCH] MP-24 Epic 4: Fertigstellung von MP-24: Authentication DI Refactoring & Cleanup Das Refactoring der Authentifizierungs-Komponenten auf Dependency Injection (Koin) wurde verifiziert und abgeschlossen. Alle manuellen Instanziierungen wurden entfernt und die korrekte Initialisierung in allen Entry-Points sichergestellt. --- build.gradle.kts | 2 + .../clients/authfeature/AuthApiClient.kt | 9 ++- .../authfeature/AuthenticatedHttpClient.kt | 62 ---------------- .../mocode/clients/authfeature/LoginScreen.kt | 4 +- .../clients/authfeature/LoginViewModel.kt | 9 +-- .../authfeature/di/AuthFeatureModule.kt | 9 +++ .../meldestelle-portal/build.gradle.kts | 2 + .../src/commonMain/kotlin/MainApp.kt | 71 +++++++++---------- .../src/wasmJsMain/kotlin/main.kt | 12 ++++ 9 files changed, 66 insertions(+), 114 deletions(-) delete mode 100644 frontend/features/auth-feature/src/commonMain/kotlin/at/mocode/clients/authfeature/AuthenticatedHttpClient.kt diff --git a/build.gradle.kts b/build.gradle.kts index ce143113..a35ef060 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -164,6 +164,8 @@ tasks.register("archGuardForbiddenAuthorizationHeader") { "setHeader(\"Authorization\"", "headers[\"Authorization\"]", "headers[\'Authorization\']", + ".header(HttpHeaders.Authorization", + "header(HttpHeaders.Authorization", ) // Scope: Frontend-only enforcement. Backend/Test code is excluded. val srcDirs = listOf("clients", "frontend") diff --git a/frontend/features/auth-feature/src/commonMain/kotlin/at/mocode/clients/authfeature/AuthApiClient.kt b/frontend/features/auth-feature/src/commonMain/kotlin/at/mocode/clients/authfeature/AuthApiClient.kt index 2811e055..69a681ca 100644 --- a/frontend/features/auth-feature/src/commonMain/kotlin/at/mocode/clients/authfeature/AuthApiClient.kt +++ b/frontend/features/auth-feature/src/commonMain/kotlin/at/mocode/clients/authfeature/AuthApiClient.kt @@ -6,7 +6,6 @@ import io.ktor.client.request.forms.* import io.ktor.http.* import kotlinx.serialization.Serializable import io.ktor.client.HttpClient -import org.koin.core.context.GlobalContext import org.koin.core.qualifier.named /** @@ -31,6 +30,7 @@ data class LoginResponse( * HTTP client for authentication API calls */ class AuthApiClient( + private val httpClient: HttpClient, // Keycloak Basis-URL (z. B. http://localhost:8180) private val keycloakBaseUrl: String = AppConstants.KEYCLOAK_URL, // Realm-Name in Keycloak @@ -40,7 +40,6 @@ class AuthApiClient( // Optional: Client-Secret (nur bei vertraulichen Clients erforderlich) private val clientSecret: String? = null ) { - private val client: HttpClient by lazy { GlobalContext.get().koin.get(named("apiClient")) } /** * Authenticate user with username and password @@ -48,7 +47,7 @@ class AuthApiClient( suspend fun login(username: String, password: String): LoginResponse { val tokenEndpoint = "$keycloakBaseUrl/realms/$realm/protocol/openid-connect/token" return try { - val response = client.submitForm( + val response = httpClient.submitForm( url = tokenEndpoint, formParameters = Parameters.build { append("grant_type", "password") @@ -93,7 +92,7 @@ class AuthApiClient( suspend fun exchangeAuthorizationCode(code: String, codeVerifier: String, redirectUri: String): LoginResponse { val tokenEndpoint = "$keycloakBaseUrl/realms/$realm/protocol/openid-connect/token" return try { - val response = client.submitForm( + val response = httpClient.submitForm( url = tokenEndpoint, formParameters = Parameters.build { append("grant_type", "authorization_code") @@ -136,7 +135,7 @@ class AuthApiClient( suspend fun refreshToken(refreshToken: String): LoginResponse { val tokenEndpoint = "$keycloakBaseUrl/realms/$realm/protocol/openid-connect/token" return try { - val response = client.submitForm( + val response = httpClient.submitForm( url = tokenEndpoint, formParameters = Parameters.build { append("grant_type", "refresh_token") diff --git a/frontend/features/auth-feature/src/commonMain/kotlin/at/mocode/clients/authfeature/AuthenticatedHttpClient.kt b/frontend/features/auth-feature/src/commonMain/kotlin/at/mocode/clients/authfeature/AuthenticatedHttpClient.kt deleted file mode 100644 index ae03f96f..00000000 --- a/frontend/features/auth-feature/src/commonMain/kotlin/at/mocode/clients/authfeature/AuthenticatedHttpClient.kt +++ /dev/null @@ -1,62 +0,0 @@ -package at.mocode.clients.authfeature - -import at.mocode.shared.core.AppConstants -import io.ktor.client.* -import io.ktor.client.plugins.contentnegotiation.* -import io.ktor.client.request.* -import io.ktor.http.* -import io.ktor.serialization.kotlinx.json.* -import kotlinx.serialization.json.Json - -/** - * Singleton object for managing authenticated HTTP client configuration. - * Provides methods to create HTTP clients and add authentication headers manually. - */ -object AuthenticatedHttpClient { - - private val authTokenManager = AuthTokenManager() - - /** - * Create a basic HTTP client with JSON support - */ - fun create(baseUrl: String = AppConstants.GATEWAY_URL): HttpClient { - return HttpClient { - install(ContentNegotiation) { - json(Json { - prettyPrint = true - isLenient = true - ignoreUnknownKeys = true - }) - } - } - } - - /** - * Add an authentication header to an HTTP request builder if a token is available - */ - fun HttpRequestBuilder.addAuthHeader() { - authTokenManager.getBearerToken()?.let { bearerToken -> - header(HttpHeaders.Authorization, bearerToken) - } - } - - /** - * Get the shared AuthTokenManager instance - */ - fun getAuthTokenManager(): AuthTokenManager = authTokenManager - - /** - * Create an HTTP client without authentication (for login/public endpoints) - */ - fun createUnauthenticated(): HttpClient { - return HttpClient { - install(ContentNegotiation) { - json(Json { - prettyPrint = true - isLenient = true - ignoreUnknownKeys = true - }) - } - } - } -} diff --git a/frontend/features/auth-feature/src/commonMain/kotlin/at/mocode/clients/authfeature/LoginScreen.kt b/frontend/features/auth-feature/src/commonMain/kotlin/at/mocode/clients/authfeature/LoginScreen.kt index 4a4f9cb2..845bd0cb 100644 --- a/frontend/features/auth-feature/src/commonMain/kotlin/at/mocode/clients/authfeature/LoginScreen.kt +++ b/frontend/features/auth-feature/src/commonMain/kotlin/at/mocode/clients/authfeature/LoginScreen.kt @@ -14,13 +14,11 @@ import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp -import androidx.lifecycle.viewmodel.compose.viewModel @OptIn(ExperimentalMaterial3Api::class) @Composable fun LoginScreen( - authTokenManager: AuthTokenManager, - viewModel: LoginViewModel = viewModel { LoginViewModel(authTokenManager) }, + viewModel: LoginViewModel, onLoginSuccess: () -> Unit = {} ) { val uiState by viewModel.uiState.collectAsState() diff --git a/frontend/features/auth-feature/src/commonMain/kotlin/at/mocode/clients/authfeature/LoginViewModel.kt b/frontend/features/auth-feature/src/commonMain/kotlin/at/mocode/clients/authfeature/LoginViewModel.kt index 0c395653..5ebb9ed5 100644 --- a/frontend/features/auth-feature/src/commonMain/kotlin/at/mocode/clients/authfeature/LoginViewModel.kt +++ b/frontend/features/auth-feature/src/commonMain/kotlin/at/mocode/clients/authfeature/LoginViewModel.kt @@ -3,8 +3,6 @@ package at.mocode.clients.authfeature import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import io.ktor.client.request.post -import org.koin.core.context.GlobalContext -import org.koin.core.qualifier.named import io.ktor.client.HttpClient import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -31,15 +29,14 @@ data class LoginUiState( * ViewModel for handling login authentication logic */ class LoginViewModel( - private val authTokenManager: AuthTokenManager + private val authTokenManager: AuthTokenManager, + private val authApiClient: AuthApiClient, + private val apiClient: HttpClient ) : ViewModel() { private val _uiState = MutableStateFlow(LoginUiState()) val uiState: StateFlow = _uiState.asStateFlow() - private val authApiClient = AuthApiClient() - private val apiClient: HttpClient by lazy { GlobalContext.get().koin.get(named("apiClient")) } - fun updateUsername(username: String) { _uiState.value = _uiState.value.copy( username = username, diff --git a/frontend/features/auth-feature/src/commonMain/kotlin/at/mocode/clients/authfeature/di/AuthFeatureModule.kt b/frontend/features/auth-feature/src/commonMain/kotlin/at/mocode/clients/authfeature/di/AuthFeatureModule.kt index 9aef3e42..50e866da 100644 --- a/frontend/features/auth-feature/src/commonMain/kotlin/at/mocode/clients/authfeature/di/AuthFeatureModule.kt +++ b/frontend/features/auth-feature/src/commonMain/kotlin/at/mocode/clients/authfeature/di/AuthFeatureModule.kt @@ -1,7 +1,10 @@ package at.mocode.clients.authfeature.di +import at.mocode.clients.authfeature.AuthApiClient import at.mocode.clients.authfeature.AuthTokenManager +import at.mocode.clients.authfeature.LoginViewModel import at.mocode.frontend.core.network.TokenProvider +import org.koin.core.qualifier.named import org.koin.dsl.module /** @@ -11,6 +14,12 @@ val authFeatureModule = module { // Single in-memory token manager single { AuthTokenManager() } + // AuthApiClient with injected apiClient + single { AuthApiClient(get(named("apiClient"))) } + + // LoginViewModel + factory { LoginViewModel(get(), get(), get(named("apiClient"))) } + // Bridge to core network TokenProvider without adding a hard dependency there single { object : TokenProvider { diff --git a/frontend/shells/meldestelle-portal/build.gradle.kts b/frontend/shells/meldestelle-portal/build.gradle.kts index c8c49a53..70aa4121 100644 --- a/frontend/shells/meldestelle-portal/build.gradle.kts +++ b/frontend/shells/meldestelle-portal/build.gradle.kts @@ -85,6 +85,8 @@ kotlin { // DI (Koin) needed to call initKoin { modules(...) } implementation(libs.koin.core) + implementation(libs.koin.compose) + implementation(libs.koin.compose.viewmodel) // Compose Multiplatform implementation(compose.runtime) diff --git a/frontend/shells/meldestelle-portal/src/commonMain/kotlin/MainApp.kt b/frontend/shells/meldestelle-portal/src/commonMain/kotlin/MainApp.kt index a6503da5..c5dc030a 100644 --- a/frontend/shells/meldestelle-portal/src/commonMain/kotlin/MainApp.kt +++ b/frontend/shells/meldestelle-portal/src/commonMain/kotlin/MainApp.kt @@ -7,7 +7,6 @@ import androidx.compose.ui.unit.dp import androidx.compose.runtime.collectAsState import at.mocode.clients.shared.navigation.AppScreen import at.mocode.clients.authfeature.AuthTokenManager -import org.koin.core.context.GlobalContext import at.mocode.clients.pingfeature.PingScreen import at.mocode.clients.pingfeature.PingViewModel import at.mocode.shared.core.AppConstants @@ -16,9 +15,12 @@ import androidx.compose.ui.text.input.PasswordVisualTransformation import kotlinx.coroutines.launch import androidx.compose.runtime.rememberCoroutineScope import at.mocode.clients.authfeature.AuthApiClient +import at.mocode.clients.authfeature.LoginViewModel import at.mocode.clients.authfeature.oauth.OAuthPkceService import at.mocode.clients.authfeature.oauth.AuthCallbackParams import at.mocode.clients.authfeature.oauth.CallbackParams +import org.koin.compose.koinInject +import org.koin.compose.viewmodel.koinViewModel @Composable fun MainApp() { @@ -30,7 +32,8 @@ fun MainApp() { var currentScreen by remember { mutableStateOf(AppScreen.Home) } // Resolve AuthTokenManager from Koin - val authTokenManager = remember { GlobalContext.get().koin.get() } + val authTokenManager = koinInject() + val authApiClient = koinInject() val pingViewModel = remember { PingViewModel() } val scope = rememberCoroutineScope() @@ -42,8 +45,7 @@ fun MainApp() { val state = callback.state val pkce = OAuthPkceService.current() if (pkce != null && pkce.state == state) { - val api = AuthApiClient() - val res = api.exchangeAuthorizationCode(code, pkce.codeVerifier, AppConstants.webRedirectUri()) + val res = authApiClient.exchangeAuthorizationCode(code, pkce.codeVerifier, AppConstants.webRedirectUri()) val token = res.token if (res.success && token != null) { authTokenManager.setToken(token) @@ -66,7 +68,6 @@ fun MainApp() { ) is AppScreen.Login -> LoginScreen( - authTokenManager = authTokenManager, onLoginSuccess = { currentScreen = AppScreen.Profile } ) @@ -194,15 +195,16 @@ private fun AuthStatusScreen( @Composable private fun LoginScreen( - authTokenManager: AuthTokenManager, onLoginSuccess: () -> Unit ) { - var username by remember { mutableStateOf("") } - var password by remember { mutableStateOf("") } - var error by remember { mutableStateOf(null) } - var isLoading by remember { mutableStateOf(false) } - val scope = rememberCoroutineScope() - val api = remember { AuthApiClient() } + val viewModel = koinViewModel() + val uiState by viewModel.uiState.collectAsState() + + LaunchedEffect(uiState.isAuthenticated) { + if (uiState.isAuthenticated) { + onLoginSuccess() + } + } Column( modifier = Modifier @@ -213,48 +215,41 @@ private fun LoginScreen( Text("Anmeldung", style = MaterialTheme.typography.headlineMedium) OutlinedTextField( - value = username, - onValueChange = { username = it }, + value = uiState.username, + onValueChange = { viewModel.updateUsername(it) }, label = { Text("Benutzername") }, singleLine = true, - enabled = !isLoading, + enabled = !uiState.isLoading, + isError = uiState.usernameError != null, modifier = Modifier.fillMaxWidth() ) + if (uiState.usernameError != null) { + Text(uiState.usernameError!!, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall) + } OutlinedTextField( - value = password, - onValueChange = { password = it }, + value = uiState.password, + onValueChange = { viewModel.updatePassword(it) }, label = { Text("Passwort") }, singleLine = true, - enabled = !isLoading, + enabled = !uiState.isLoading, visualTransformation = PasswordVisualTransformation(), + isError = uiState.passwordError != null, modifier = Modifier.fillMaxWidth() ) + if (uiState.passwordError != null) { + Text(uiState.passwordError!!, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall) + } - error?.let { - Text(it, color = MaterialTheme.colorScheme.error) + if (uiState.errorMessage != null) { + Text(uiState.errorMessage!!, color = MaterialTheme.colorScheme.error) } Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { Button( - onClick = { - error = null - isLoading = true - scope.launch { - val res = api.login(username.trim(), password) - val token = res.token - if (res.success && token != null) { - authTokenManager.setToken(token) - isLoading = false - onLoginSuccess() - } else { - isLoading = false - error = res.message ?: "Login fehlgeschlagen" - } - } - }, - enabled = !isLoading && username.isNotBlank() && password.isNotBlank() - ) { Text(if (isLoading) "Bitte warten…" else "Login") } + onClick = { viewModel.login() }, + enabled = uiState.canLogin + ) { Text(if (uiState.isLoading) "Bitte warten…" else "Login") } } } } diff --git a/frontend/shells/meldestelle-portal/src/wasmJsMain/kotlin/main.kt b/frontend/shells/meldestelle-portal/src/wasmJsMain/kotlin/main.kt index 9420a2ba..19f4addc 100644 --- a/frontend/shells/meldestelle-portal/src/wasmJsMain/kotlin/main.kt +++ b/frontend/shells/meldestelle-portal/src/wasmJsMain/kotlin/main.kt @@ -3,8 +3,20 @@ import androidx.compose.ui.window.ComposeViewport import kotlinx.browser.document import org.w3c.dom.HTMLElement +import at.mocode.shared.di.initKoin +import at.mocode.frontend.core.network.networkModule +import at.mocode.clients.authfeature.di.authFeatureModule + @OptIn(ExperimentalComposeUiApi::class) fun main() { + // Initialize DI + try { + initKoin { modules(networkModule, authFeatureModule) } + println("[WasmApp] Koin initialized") + } catch (e: Exception) { + println("[WasmApp] Koin init failed: ${e.message}") + } + val root = document.getElementById("ComposeTarget") as HTMLElement ComposeViewport(root) { MainApp()