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.
This commit is contained in:
Stefan Mogeritsch 2025-12-06 22:32:12 +01:00
parent 7d9d729d7d
commit afd109efcc
9 changed files with 66 additions and 114 deletions

View File

@ -164,6 +164,8 @@ tasks.register("archGuardForbiddenAuthorizationHeader") {
"setHeader(\"Authorization\"", "setHeader(\"Authorization\"",
"headers[\"Authorization\"]", "headers[\"Authorization\"]",
"headers[\'Authorization\']", "headers[\'Authorization\']",
".header(HttpHeaders.Authorization",
"header(HttpHeaders.Authorization",
) )
// Scope: Frontend-only enforcement. Backend/Test code is excluded. // Scope: Frontend-only enforcement. Backend/Test code is excluded.
val srcDirs = listOf("clients", "frontend") val srcDirs = listOf("clients", "frontend")

View File

@ -6,7 +6,6 @@ import io.ktor.client.request.forms.*
import io.ktor.http.* import io.ktor.http.*
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import io.ktor.client.HttpClient import io.ktor.client.HttpClient
import org.koin.core.context.GlobalContext
import org.koin.core.qualifier.named import org.koin.core.qualifier.named
/** /**
@ -31,6 +30,7 @@ data class LoginResponse(
* HTTP client for authentication API calls * HTTP client for authentication API calls
*/ */
class AuthApiClient( class AuthApiClient(
private val httpClient: HttpClient,
// Keycloak Basis-URL (z. B. http://localhost:8180) // Keycloak Basis-URL (z. B. http://localhost:8180)
private val keycloakBaseUrl: String = AppConstants.KEYCLOAK_URL, private val keycloakBaseUrl: String = AppConstants.KEYCLOAK_URL,
// Realm-Name in Keycloak // Realm-Name in Keycloak
@ -40,7 +40,6 @@ class AuthApiClient(
// Optional: Client-Secret (nur bei vertraulichen Clients erforderlich) // Optional: Client-Secret (nur bei vertraulichen Clients erforderlich)
private val clientSecret: String? = null private val clientSecret: String? = null
) { ) {
private val client: HttpClient by lazy { GlobalContext.get().koin.get<HttpClient>(named("apiClient")) }
/** /**
* Authenticate user with username and password * Authenticate user with username and password
@ -48,7 +47,7 @@ class AuthApiClient(
suspend fun login(username: String, password: String): LoginResponse { suspend fun login(username: String, password: String): LoginResponse {
val tokenEndpoint = "$keycloakBaseUrl/realms/$realm/protocol/openid-connect/token" val tokenEndpoint = "$keycloakBaseUrl/realms/$realm/protocol/openid-connect/token"
return try { return try {
val response = client.submitForm( val response = httpClient.submitForm(
url = tokenEndpoint, url = tokenEndpoint,
formParameters = Parameters.build { formParameters = Parameters.build {
append("grant_type", "password") append("grant_type", "password")
@ -93,7 +92,7 @@ class AuthApiClient(
suspend fun exchangeAuthorizationCode(code: String, codeVerifier: String, redirectUri: String): LoginResponse { suspend fun exchangeAuthorizationCode(code: String, codeVerifier: String, redirectUri: String): LoginResponse {
val tokenEndpoint = "$keycloakBaseUrl/realms/$realm/protocol/openid-connect/token" val tokenEndpoint = "$keycloakBaseUrl/realms/$realm/protocol/openid-connect/token"
return try { return try {
val response = client.submitForm( val response = httpClient.submitForm(
url = tokenEndpoint, url = tokenEndpoint,
formParameters = Parameters.build { formParameters = Parameters.build {
append("grant_type", "authorization_code") append("grant_type", "authorization_code")
@ -136,7 +135,7 @@ class AuthApiClient(
suspend fun refreshToken(refreshToken: String): LoginResponse { suspend fun refreshToken(refreshToken: String): LoginResponse {
val tokenEndpoint = "$keycloakBaseUrl/realms/$realm/protocol/openid-connect/token" val tokenEndpoint = "$keycloakBaseUrl/realms/$realm/protocol/openid-connect/token"
return try { return try {
val response = client.submitForm( val response = httpClient.submitForm(
url = tokenEndpoint, url = tokenEndpoint,
formParameters = Parameters.build { formParameters = Parameters.build {
append("grant_type", "refresh_token") append("grant_type", "refresh_token")

View File

@ -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
})
}
}
}
}

View File

@ -14,13 +14,11 @@ import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun LoginScreen( fun LoginScreen(
authTokenManager: AuthTokenManager, viewModel: LoginViewModel,
viewModel: LoginViewModel = viewModel { LoginViewModel(authTokenManager) },
onLoginSuccess: () -> Unit = {} onLoginSuccess: () -> Unit = {}
) { ) {
val uiState by viewModel.uiState.collectAsState() val uiState by viewModel.uiState.collectAsState()

View File

@ -3,8 +3,6 @@ package at.mocode.clients.authfeature
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import io.ktor.client.request.post import io.ktor.client.request.post
import org.koin.core.context.GlobalContext
import org.koin.core.qualifier.named
import io.ktor.client.HttpClient import io.ktor.client.HttpClient
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@ -31,15 +29,14 @@ data class LoginUiState(
* ViewModel for handling login authentication logic * ViewModel for handling login authentication logic
*/ */
class LoginViewModel( class LoginViewModel(
private val authTokenManager: AuthTokenManager private val authTokenManager: AuthTokenManager,
private val authApiClient: AuthApiClient,
private val apiClient: HttpClient
) : ViewModel() { ) : ViewModel() {
private val _uiState = MutableStateFlow(LoginUiState()) private val _uiState = MutableStateFlow(LoginUiState())
val uiState: StateFlow<LoginUiState> = _uiState.asStateFlow() val uiState: StateFlow<LoginUiState> = _uiState.asStateFlow()
private val authApiClient = AuthApiClient()
private val apiClient: HttpClient by lazy { GlobalContext.get().koin.get<HttpClient>(named("apiClient")) }
fun updateUsername(username: String) { fun updateUsername(username: String) {
_uiState.value = _uiState.value.copy( _uiState.value = _uiState.value.copy(
username = username, username = username,

View File

@ -1,7 +1,10 @@
package at.mocode.clients.authfeature.di package at.mocode.clients.authfeature.di
import at.mocode.clients.authfeature.AuthApiClient
import at.mocode.clients.authfeature.AuthTokenManager import at.mocode.clients.authfeature.AuthTokenManager
import at.mocode.clients.authfeature.LoginViewModel
import at.mocode.frontend.core.network.TokenProvider import at.mocode.frontend.core.network.TokenProvider
import org.koin.core.qualifier.named
import org.koin.dsl.module import org.koin.dsl.module
/** /**
@ -11,6 +14,12 @@ val authFeatureModule = module {
// Single in-memory token manager // Single in-memory token manager
single { AuthTokenManager() } 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 // Bridge to core network TokenProvider without adding a hard dependency there
single<TokenProvider> { single<TokenProvider> {
object : TokenProvider { object : TokenProvider {

View File

@ -85,6 +85,8 @@ kotlin {
// DI (Koin) needed to call initKoin { modules(...) } // DI (Koin) needed to call initKoin { modules(...) }
implementation(libs.koin.core) implementation(libs.koin.core)
implementation(libs.koin.compose)
implementation(libs.koin.compose.viewmodel)
// Compose Multiplatform // Compose Multiplatform
implementation(compose.runtime) implementation(compose.runtime)

View File

@ -7,7 +7,6 @@ import androidx.compose.ui.unit.dp
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import at.mocode.clients.shared.navigation.AppScreen import at.mocode.clients.shared.navigation.AppScreen
import at.mocode.clients.authfeature.AuthTokenManager import at.mocode.clients.authfeature.AuthTokenManager
import org.koin.core.context.GlobalContext
import at.mocode.clients.pingfeature.PingScreen import at.mocode.clients.pingfeature.PingScreen
import at.mocode.clients.pingfeature.PingViewModel import at.mocode.clients.pingfeature.PingViewModel
import at.mocode.shared.core.AppConstants import at.mocode.shared.core.AppConstants
@ -16,9 +15,12 @@ import androidx.compose.ui.text.input.PasswordVisualTransformation
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import at.mocode.clients.authfeature.AuthApiClient 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.OAuthPkceService
import at.mocode.clients.authfeature.oauth.AuthCallbackParams import at.mocode.clients.authfeature.oauth.AuthCallbackParams
import at.mocode.clients.authfeature.oauth.CallbackParams import at.mocode.clients.authfeature.oauth.CallbackParams
import org.koin.compose.koinInject
import org.koin.compose.viewmodel.koinViewModel
@Composable @Composable
fun MainApp() { fun MainApp() {
@ -30,7 +32,8 @@ fun MainApp() {
var currentScreen by remember { mutableStateOf<AppScreen>(AppScreen.Home) } var currentScreen by remember { mutableStateOf<AppScreen>(AppScreen.Home) }
// Resolve AuthTokenManager from Koin // Resolve AuthTokenManager from Koin
val authTokenManager = remember { GlobalContext.get().koin.get<AuthTokenManager>() } val authTokenManager = koinInject<AuthTokenManager>()
val authApiClient = koinInject<AuthApiClient>()
val pingViewModel = remember { PingViewModel() } val pingViewModel = remember { PingViewModel() }
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
@ -42,8 +45,7 @@ fun MainApp() {
val state = callback.state val state = callback.state
val pkce = OAuthPkceService.current() val pkce = OAuthPkceService.current()
if (pkce != null && pkce.state == state) { if (pkce != null && pkce.state == state) {
val api = AuthApiClient() val res = authApiClient.exchangeAuthorizationCode(code, pkce.codeVerifier, AppConstants.webRedirectUri())
val res = api.exchangeAuthorizationCode(code, pkce.codeVerifier, AppConstants.webRedirectUri())
val token = res.token val token = res.token
if (res.success && token != null) { if (res.success && token != null) {
authTokenManager.setToken(token) authTokenManager.setToken(token)
@ -66,7 +68,6 @@ fun MainApp() {
) )
is AppScreen.Login -> LoginScreen( is AppScreen.Login -> LoginScreen(
authTokenManager = authTokenManager,
onLoginSuccess = { currentScreen = AppScreen.Profile } onLoginSuccess = { currentScreen = AppScreen.Profile }
) )
@ -194,15 +195,16 @@ private fun AuthStatusScreen(
@Composable @Composable
private fun LoginScreen( private fun LoginScreen(
authTokenManager: AuthTokenManager,
onLoginSuccess: () -> Unit onLoginSuccess: () -> Unit
) { ) {
var username by remember { mutableStateOf("") } val viewModel = koinViewModel<LoginViewModel>()
var password by remember { mutableStateOf("") } val uiState by viewModel.uiState.collectAsState()
var error by remember { mutableStateOf<String?>(null) }
var isLoading by remember { mutableStateOf(false) } LaunchedEffect(uiState.isAuthenticated) {
val scope = rememberCoroutineScope() if (uiState.isAuthenticated) {
val api = remember { AuthApiClient() } onLoginSuccess()
}
}
Column( Column(
modifier = Modifier modifier = Modifier
@ -213,48 +215,41 @@ private fun LoginScreen(
Text("Anmeldung", style = MaterialTheme.typography.headlineMedium) Text("Anmeldung", style = MaterialTheme.typography.headlineMedium)
OutlinedTextField( OutlinedTextField(
value = username, value = uiState.username,
onValueChange = { username = it }, onValueChange = { viewModel.updateUsername(it) },
label = { Text("Benutzername") }, label = { Text("Benutzername") },
singleLine = true, singleLine = true,
enabled = !isLoading, enabled = !uiState.isLoading,
isError = uiState.usernameError != null,
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) )
if (uiState.usernameError != null) {
Text(uiState.usernameError!!, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall)
}
OutlinedTextField( OutlinedTextField(
value = password, value = uiState.password,
onValueChange = { password = it }, onValueChange = { viewModel.updatePassword(it) },
label = { Text("Passwort") }, label = { Text("Passwort") },
singleLine = true, singleLine = true,
enabled = !isLoading, enabled = !uiState.isLoading,
visualTransformation = PasswordVisualTransformation(), visualTransformation = PasswordVisualTransformation(),
isError = uiState.passwordError != null,
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) )
if (uiState.passwordError != null) {
Text(uiState.passwordError!!, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall)
}
error?.let { if (uiState.errorMessage != null) {
Text(it, color = MaterialTheme.colorScheme.error) Text(uiState.errorMessage!!, color = MaterialTheme.colorScheme.error)
} }
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Button( Button(
onClick = { onClick = { viewModel.login() },
error = null enabled = uiState.canLogin
isLoading = true ) { Text(if (uiState.isLoading) "Bitte warten…" else "Login") }
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") }
} }
} }
} }

View File

@ -3,8 +3,20 @@ import androidx.compose.ui.window.ComposeViewport
import kotlinx.browser.document import kotlinx.browser.document
import org.w3c.dom.HTMLElement 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) @OptIn(ExperimentalComposeUiApi::class)
fun main() { 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 val root = document.getElementById("ComposeTarget") as HTMLElement
ComposeViewport(root) { ComposeViewport(root) {
MainApp() MainApp()