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:
parent
7d9d729d7d
commit
afd109efcc
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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<HttpClient>(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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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<LoginUiState> = _uiState.asStateFlow()
|
||||
|
||||
private val authApiClient = AuthApiClient()
|
||||
private val apiClient: HttpClient by lazy { GlobalContext.get().koin.get<HttpClient>(named("apiClient")) }
|
||||
|
||||
fun updateUsername(username: String) {
|
||||
_uiState.value = _uiState.value.copy(
|
||||
username = username,
|
||||
|
|
|
|||
|
|
@ -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<TokenProvider> {
|
||||
object : TokenProvider {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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>(AppScreen.Home) }
|
||||
|
||||
// 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 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<String?>(null) }
|
||||
var isLoading by remember { mutableStateOf(false) }
|
||||
val scope = rememberCoroutineScope()
|
||||
val api = remember { AuthApiClient() }
|
||||
val viewModel = koinViewModel<LoginViewModel>()
|
||||
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") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user