feature Keycloak Auth

This commit is contained in:
2025-10-06 00:17:18 +02:00
parent 1ed5f3bfca
commit 82b1a2679d
39 changed files with 1963 additions and 210 deletions
@@ -0,0 +1,99 @@
package at.mocode.clients.authfeature
import io.ktor.client.call.*
import io.ktor.client.request.*
import io.ktor.http.*
import kotlinx.serialization.Serializable
/**
* Data classes for authentication API communication
*/
@Serializable
data class LoginRequest(
val username: String,
val password: String
)
@Serializable
data class LoginResponse(
val success: Boolean,
val token: String? = null,
val message: String? = null,
val userId: String? = null,
val username: String? = null
)
/**
* HTTP client for authentication API calls
*/
class AuthApiClient(
private val baseUrl: String = "http://localhost:8081"
) {
private val client = AuthenticatedHttpClient.createUnauthenticated()
/**
* Authenticate user with username and password
*/
suspend fun login(username: String, password: String): LoginResponse {
return try {
val response = client.post("$baseUrl/api/auth/login") {
contentType(ContentType.Application.Json)
setBody(LoginRequest(username = username, password = password))
}
if (response.status.isSuccess()) {
response.body<LoginResponse>()
} else {
LoginResponse(
success = false,
message = "Login fehlgeschlagen: HTTP ${response.status.value}"
)
}
} catch (e: Exception) {
LoginResponse(
success = false,
message = "Verbindungsfehler: ${e.message}"
)
}
}
/**
* Refresh authentication token
*/
suspend fun refreshToken(token: String): LoginResponse {
return try {
val response = client.post("$baseUrl/api/auth/refresh") {
contentType(ContentType.Application.Json)
header(HttpHeaders.Authorization, "Bearer $token")
}
if (response.status.isSuccess()) {
response.body<LoginResponse>()
} else {
LoginResponse(
success = false,
message = "Token refresh fehlgeschlagen: HTTP ${response.status.value}"
)
}
} catch (e: Exception) {
LoginResponse(
success = false,
message = "Token refresh Fehler: ${e.message}"
)
}
}
/**
* Logout and invalidate token
*/
suspend fun logout(token: String): Boolean {
return try {
val response = client.post("$baseUrl/api/auth/logout") {
header(HttpHeaders.Authorization, "Bearer $token")
}
response.status.isSuccess()
} catch (_: Exception) {
false // Logout failed, but we'll clear local token anyway
}
}
}
@@ -0,0 +1,344 @@
package at.mocode.clients.authfeature
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.longOrNull
import kotlinx.serialization.json.contentOrNull
import kotlin.io.encoding.Base64
import kotlin.io.encoding.ExperimentalEncodingApi
import kotlin.time.ExperimentalTime
/**
* Client-side permission enumeration that mirrors server-side BerechtigungE
*/
@Serializable
enum class Permission {
// 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
}
/**
* JWT token payload for basic validation and permissions extraction
*/
@Serializable
data class JwtPayload(
val sub: String? = null, // User ID
val username: String? = null, // Username
val exp: Long? = null, // Expiration timestamp
val iat: Long? = null, // Issued at timestamp
val iss: String? = null, // Issuer
val permissions: List<String>? = null // Permissions array
)
/**
* Authentication state
*/
data class AuthState(
val isAuthenticated: Boolean = false,
val token: String? = null,
val userId: String? = null,
val username: String? = null,
val permissions: List<Permission> = emptyList()
)
/**
* Secure in-memory JWT token manager
*
* For web clients, storing tokens in memory is the most secure approach
* to prevent XSS attacks. The token is lost when the browser tab is closed
* or refreshed, requiring re-authentication.
*/
class AuthTokenManager {
private var currentToken: String? = null
private var tokenPayload: JwtPayload? = null
private val _authState = MutableStateFlow(AuthState())
val authState: StateFlow<AuthState> = _authState.asStateFlow()
/**
* Store JWT token in memory
*/
fun setToken(token: String) {
currentToken = token
tokenPayload = parseJwtPayload(token)
// Parse permissions from token payload
val permissions = tokenPayload?.permissions?.mapNotNull { permissionString ->
try {
Permission.valueOf(permissionString)
} catch (e: IllegalArgumentException) {
// Ignore unknown permissions
null
}
} ?: emptyList()
_authState.value = AuthState(
isAuthenticated = true,
token = token,
userId = tokenPayload?.sub,
username = tokenPayload?.username,
permissions = permissions
)
}
/**
* Get current JWT token
*/
fun getToken(): String? = currentToken
/**
* Check if we have a valid (non-expired) token
*/
@OptIn(ExperimentalTime::class)
fun hasValidToken(): Boolean {
val token = currentToken ?: return false
val payload = tokenPayload ?: return false
// Check expiration
val expiration = payload.exp ?: return false
val currentTime = kotlin.time.Clock.System.now().epochSeconds
return currentTime < expiration
}
/**
* Clear token from memory (logout)
*/
fun clearToken() {
currentToken = null
tokenPayload = null
_authState.value = AuthState()
}
/**
* Get user ID from token
*/
fun getUserId(): String? = tokenPayload?.sub
/**
* Get username from token
*/
fun getUsername(): String? = tokenPayload?.username
/**
* Get current user permissions
*/
fun getPermissions(): List<Permission> = _authState.value.permissions
/**
* Check if user has a specific permission
*/
fun hasPermission(permission: Permission): Boolean {
return _authState.value.permissions.contains(permission)
}
/**
* Check if user has any of the specified permissions
*/
fun hasAnyPermission(vararg permissions: Permission): Boolean {
return permissions.any { _authState.value.permissions.contains(it) }
}
/**
* Check if user has all of the specified permissions
*/
fun hasAllPermissions(vararg permissions: Permission): Boolean {
return permissions.all { _authState.value.permissions.contains(it) }
}
/**
* Check if user can perform read operations
*/
fun canRead(): Boolean {
return hasAnyPermission(
Permission.PERSON_READ,
Permission.VEREIN_READ,
Permission.VERANSTALTUNG_READ,
Permission.PFERD_READ
)
}
/**
* Check if user can perform create operations
*/
fun canCreate(): Boolean {
return hasAnyPermission(
Permission.PERSON_CREATE,
Permission.VEREIN_CREATE,
Permission.VERANSTALTUNG_CREATE,
Permission.PFERD_CREATE
)
}
/**
* Check if user can perform update operations
*/
fun canUpdate(): Boolean {
return hasAnyPermission(
Permission.PERSON_UPDATE,
Permission.VEREIN_UPDATE,
Permission.VERANSTALTUNG_UPDATE,
Permission.PFERD_UPDATE
)
}
/**
* Check if user can perform delete operations (admin-level)
*/
fun canDelete(): Boolean {
return hasAnyPermission(
Permission.PERSON_DELETE,
Permission.VEREIN_DELETE,
Permission.VERANSTALTUNG_DELETE,
Permission.PFERD_DELETE
)
}
/**
* Check if user is admin (has delete permissions)
*/
fun isAdmin(): Boolean = canDelete()
/**
* Check if token expires within specified minutes
*/
@OptIn(ExperimentalTime::class)
fun isTokenExpiringSoon(minutesThreshold: Int = 5): Boolean {
val payload = tokenPayload ?: return false
val expiration = payload.exp ?: return false
val currentTime = kotlin.time.Clock.System.now().epochSeconds
val thresholdTime = currentTime + (minutesThreshold * 60)
return expiration <= thresholdTime
}
/**
* Parse JWT payload for basic validation and user info extraction
* Note: This is for client-side info extraction only, not security validation
*/
@OptIn(ExperimentalEncodingApi::class)
private fun parseJwtPayload(token: String): JwtPayload? {
return try {
val parts = token.split(".")
if (parts.size != 3) return null
// Decode the payload (second part)
val payloadJson = Base64.decode(parts[1]).decodeToString()
// First try to parse with standard approach
val basicPayload = try {
Json.decodeFromString<JwtPayload>(payloadJson)
} catch (e: Exception) {
// If that fails, extract manually
null
}
// If basic parsing succeeded and has permissions, return it
if (basicPayload != null && basicPayload.permissions != null) {
return basicPayload
}
// Otherwise, extract permissions manually from JSON string
val permissions = extractPermissionsFromJson(payloadJson)
// Return payload with manually extracted permissions
JwtPayload(
sub = basicPayload?.sub,
username = basicPayload?.username,
exp = basicPayload?.exp,
iat = basicPayload?.iat,
iss = basicPayload?.iss,
permissions = permissions
)
} catch (e: Exception) {
// Failed to parse - token might be invalid format
null
}
}
/**
* Extract permissions array from JSON string using simple string parsing
*/
private fun extractPermissionsFromJson(jsonString: String): List<String>? {
return try {
// Simple regex to find permissions array
val permissionsRegex = """"permissions":\s*\[(.*?)\]""".toRegex()
val match = permissionsRegex.find(jsonString)
match?.let {
val permissionsContent = it.groupValues[1]
if (permissionsContent.isBlank()) return emptyList()
// Extract individual permission strings
val permissions = permissionsContent
.split(",")
.mapNotNull { permission ->
permission.trim()
.removePrefix("\"")
.removeSuffix("\"")
.takeIf { it.isNotBlank() }
}
permissions
}
} catch (e: Exception) {
null
}
}
/**
* Get token with Bearer prefix for HTTP headers
*/
fun getBearerToken(): String? {
val token = getToken() ?: return null
return "Bearer $token"
}
/**
* Refresh token if needed based on expiry
*/
suspend fun refreshTokenIfNeeded(authApiClient: AuthApiClient): Boolean {
if (!isTokenExpiringSoon()) return true
val currentToken = getToken() ?: return false
val refreshResponse = authApiClient.refreshToken(currentToken)
if (refreshResponse.success && refreshResponse.token != null) {
setToken(refreshResponse.token)
return true
}
// Refresh failed, clear token
clearToken()
return false
}
}
@@ -0,0 +1,61 @@
package at.mocode.clients.authfeature
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 = "http://localhost:8081"): 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
})
}
}
}
}
@@ -0,0 +1,136 @@
package at.mocode.clients.authfeature
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.text.input.ImeAction
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) },
onLoginSuccess: () -> Unit = {}
) {
val uiState by viewModel.uiState.collectAsState()
val passwordFocusRequester = remember { FocusRequester() }
Column(
modifier = Modifier
.fillMaxSize()
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
// Title
Text(
text = "Anmelden",
style = MaterialTheme.typography.headlineMedium,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.padding(bottom = 32.dp)
)
// Username field
OutlinedTextField(
value = uiState.username,
onValueChange = viewModel::updateUsername,
label = { Text("Benutzername") },
enabled = !uiState.isLoading,
isError = uiState.usernameError != null,
supportingText = uiState.usernameError?.let { { Text(it) } },
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Text,
imeAction = ImeAction.Next
),
keyboardActions = KeyboardActions(
onNext = { passwordFocusRequester.requestFocus() }
),
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 16.dp)
)
// Password field
OutlinedTextField(
value = uiState.password,
onValueChange = viewModel::updatePassword,
label = { Text("Passwort") },
enabled = !uiState.isLoading,
isError = uiState.passwordError != null,
supportingText = uiState.passwordError?.let { { Text(it) } },
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Password,
imeAction = ImeAction.Done
),
keyboardActions = KeyboardActions(
onDone = {
if (uiState.canLogin) {
viewModel.login()
}
}
),
modifier = Modifier
.fillMaxWidth()
.focusRequester(passwordFocusRequester)
.padding(bottom = 24.dp)
)
// Error message
if (uiState.errorMessage != null) {
Card(
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.errorContainer
),
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 16.dp)
) {
Text(
text = uiState.errorMessage!!,
color = MaterialTheme.colorScheme.onErrorContainer,
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center,
modifier = Modifier.padding(16.dp)
)
}
}
// Login button
Button(
onClick = { viewModel.login() },
enabled = uiState.canLogin && !uiState.isLoading,
modifier = Modifier
.fillMaxWidth()
.height(48.dp)
) {
if (uiState.isLoading) {
CircularProgressIndicator(
modifier = Modifier.size(20.dp),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.onPrimary
)
} else {
Text("Anmelden")
}
}
}
// Handle login success
LaunchedEffect(uiState.isAuthenticated) {
if (uiState.isAuthenticated) {
onLoginSuccess()
}
}
}
@@ -0,0 +1,116 @@
package at.mocode.clients.authfeature
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
/**
* UI state for the login screen
*/
data class LoginUiState(
val username: String = "",
val password: String = "",
val isLoading: Boolean = false,
val isAuthenticated: Boolean = false,
val errorMessage: String? = null,
val usernameError: String? = null,
val passwordError: String? = null
) {
val canLogin: Boolean
get() = username.isNotBlank() && password.isNotBlank() && !isLoading
}
/**
* ViewModel for handling login authentication logic
*/
class LoginViewModel(
private val authTokenManager: AuthTokenManager
) : ViewModel() {
private val _uiState = MutableStateFlow(LoginUiState())
val uiState: StateFlow<LoginUiState> = _uiState.asStateFlow()
private val authApiClient = AuthApiClient()
fun updateUsername(username: String) {
_uiState.value = _uiState.value.copy(
username = username,
usernameError = null,
errorMessage = null
)
}
fun updatePassword(password: String) {
_uiState.value = _uiState.value.copy(
password = password,
passwordError = null,
errorMessage = null
)
}
fun login() {
val currentState = _uiState.value
// Validate input
if (currentState.username.isBlank()) {
_uiState.value = currentState.copy(usernameError = "Benutzername ist erforderlich")
return
}
if (currentState.password.isBlank()) {
_uiState.value = currentState.copy(passwordError = "Passwort ist erforderlich")
return
}
// Start the login process
_uiState.value = currentState.copy(
isLoading = true,
errorMessage = null,
usernameError = null,
passwordError = null
)
viewModelScope.launch {
try {
val loginResponse = authApiClient.login(
username = currentState.username,
password = currentState.password
)
if (loginResponse.success && loginResponse.token != null) {
// Store the JWT token
authTokenManager.setToken(loginResponse.token)
_uiState.value = _uiState.value.copy(
isLoading = false,
isAuthenticated = true,
errorMessage = null
)
} else {
_uiState.value = _uiState.value.copy(
isLoading = false,
errorMessage = loginResponse.message ?: "Anmeldung fehlgeschlagen"
)
}
} catch (e: Exception) {
_uiState.value = _uiState.value.copy(
isLoading = false,
errorMessage = "Verbindungsfehler: ${e.message}"
)
}
}
}
fun logout() {
authTokenManager.clearToken()
_uiState.value = LoginUiState()
}
fun checkAuthenticationStatus() {
val isAuthenticated = authTokenManager.hasValidToken()
_uiState.value = _uiState.value.copy(isAuthenticated = isAuthenticated)
}
}