From 10639f2378579004055b6f601627e12d8b1d3aaa Mon Sep 17 00:00:00 2001 From: LTFan Date: Sun, 18 Feb 2024 01:15:15 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E8=87=AA=E5=8A=A8=E6=B3=A8=E5=86=8C?= =?UTF-8?q?=E5=8F=8A=E6=B8=B8=E5=AE=A2=E6=B8=85=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build-number.properties | 2 +- build.gradle.kts | 7 + .../data/components/root/SignInComponent.kt | 57 +++++--- .../fhraise/ui/pages/root/SignIn.kt | 6 + gradle/libs.versions.toml | 3 + server/build.gradle.kts | 1 + .../xyz/xfqlittlefan/fhraise/Application.kt | 5 + .../xyz/xfqlittlefan/fhraise/api/Auth.kt | 17 +++ .../xyz/xfqlittlefan/fhraise/auth/Keycloak.kt | 93 ++++++++++++- .../xyz/xfqlittlefan/fhraise/auth/OAuth.kt | 57 ++++---- .../fhraise/models/Representations.kt | 123 ++++++++++++++++++ .../xyz/xfqlittlefan/fhraise/models/User.kt | 60 --------- shared/build.gradle.kts | 1 + .../fhraise/serializers/InstantSerializers.kt | 39 ++++++ 14 files changed, 361 insertions(+), 110 deletions(-) create mode 100644 server/src/main/kotlin/xyz/xfqlittlefan/fhraise/models/Representations.kt create mode 100644 shared/src/commonMain/kotlin/xyz/xfqlittlefan/fhraise/serializers/InstantSerializers.kt diff --git a/build-number.properties b/build-number.properties index e338dba..f161ec5 100644 --- a/build-number.properties +++ b/build-number.properties @@ -15,4 +15,4 @@ # You should have received a copy of the GNU General Public License along # with Fhraise. If not, see . # -buildNumber=45 +buildNumber=46 diff --git a/build.gradle.kts b/build.gradle.kts index 8ca4486..f76416b 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -396,6 +396,13 @@ tasks.register("runWebApp") { dependsOn("compose-app:wasmJsBrowserDevelopmentRun") } +tasks.register("runServer") { + group = "project build" + description = "Run the server" + + dependsOn("server:run") +} + tasks.register("installReleaseAndroidApp") { group = "project build" description = "Install the Android release APK" diff --git a/compose-app/src/commonMain/kotlin/xyz/xfqlittlefan/fhraise/data/components/root/SignInComponent.kt b/compose-app/src/commonMain/kotlin/xyz/xfqlittlefan/fhraise/data/components/root/SignInComponent.kt index 25f1cf8..d56d0cf 100644 --- a/compose-app/src/commonMain/kotlin/xyz/xfqlittlefan/fhraise/data/components/root/SignInComponent.kt +++ b/compose-app/src/commonMain/kotlin/xyz/xfqlittlefan/fhraise/data/components/root/SignInComponent.kt @@ -22,6 +22,8 @@ import androidx.compose.foundation.ScrollState import androidx.compose.foundation.text.KeyboardActionScope import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.* +import androidx.compose.material3.SnackbarDuration +import androidx.compose.material3.SnackbarResult import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue @@ -53,7 +55,7 @@ import xyz.xfqlittlefan.fhraise.routes.Api.Auth.Type.Request.VerificationType import kotlin.js.JsName internal typealias OnRequest = suspend (client: HttpClient, credential: String) -> Boolean -internal typealias OnVerify = suspend (client: HttpClient, verification: String) -> Boolean +internal typealias OnVerify = suspend (client: HttpClient, verification: String) -> JwtTokenPair? interface SignInComponent : AppComponentContext { var step: Step @@ -150,13 +152,12 @@ interface SignInComponent : AppComponentContext { null }?.let { verifyingToken = null - enter(it.tokenPair) - true - } ?: false + it.tokenPair + } } - VerificationType.QrCode -> { _, _ -> false } - VerificationType.Face -> { _, _ -> false } + VerificationType.QrCode -> { _, _ -> null } + VerificationType.Face -> { _, _ -> null } } val CredentialType.use: () -> Unit @@ -204,7 +205,7 @@ interface SignInComponent : AppComponentContext { fun onMicrosoftSignIn() - fun enter(tokenPair: JwtTokenPair? = null) + fun enter() val enterAction: KeyboardActionScope.() -> Unit get() = { @@ -234,7 +235,7 @@ interface SignInComponent : AppComponentContext { } class AppSignInComponent( - componentContext: AppComponentContext, private val onEnter: () -> Unit + componentContext: AppComponentContext, onEnter: (JwtTokenPair?) -> Unit ) : SignInComponent, AppComponentContext by componentContext { override var step by mutableStateOf(EnteringCredential) override var credentialType by mutableStateOf(CredentialType.Username) @@ -286,20 +287,39 @@ class AppSignInComponent( override fun onMicrosoftSignIn() = onOAuthSignIn(Api.OAuth.Provider.Microsoft) - override fun enter(tokenPair: JwtTokenPair?) { + private val onEnter: (JwtTokenPair?) -> Unit = onEnter@{ tokenPair -> + onEnter(tokenPair) + if (tokenPair != null) return@onEnter + componentScope.launch { + snackbarHostState.showSnackbar( + message = "登录账号可享受完整服务", + actionLabel = "返回登录", + withDismissAction = true, + duration = SnackbarDuration.Short + ).let { + if (it == SnackbarResult.ActionPerformed) Unit // TODO + } + } + } + + override fun enter() { if (!credentialValid) return verificationType?.let { componentScope.launch { - if (it.onVerify(client, verification)) { + it.onVerify(client, verification)?.let { snackbarHostState.showSnackbar(message = "验证成功", withDismissAction = true) - onEnter() - } else { - snackbarHostState.showSnackbar(message = "验证失败", withDismissAction = true) + onEnter(it) + } ?: snackbarHostState.showSnackbar( + message = "验证失败", + actionLabel = "仅浏览", + withDismissAction = true, + duration = SnackbarDuration.Short + ).let { + if (it == SnackbarResult.ActionPerformed) onEnter(null) } } } ?: run { - // TODO: 询问是否验证 - onEnter() + onEnter(null) } } @@ -318,7 +338,12 @@ class AppSignInComponent( if (requestVerification()) { step = Verification } else { - snackbarHostState.showSnackbar(message = "请求验证失败", withDismissAction = true) + snackbarHostState.showSnackbar( + message = "请求验证失败", + actionLabel = "仅浏览", + withDismissAction = true, + duration = SnackbarDuration.Short + ) } } } diff --git a/compose-app/src/commonMain/kotlin/xyz/xfqlittlefan/fhraise/ui/pages/root/SignIn.kt b/compose-app/src/commonMain/kotlin/xyz/xfqlittlefan/fhraise/ui/pages/root/SignIn.kt index 395ad0f..62ec4d7 100644 --- a/compose-app/src/commonMain/kotlin/xyz/xfqlittlefan/fhraise/ui/pages/root/SignIn.kt +++ b/compose-app/src/commonMain/kotlin/xyz/xfqlittlefan/fhraise/ui/pages/root/SignIn.kt @@ -180,6 +180,12 @@ fun SignInComponent.SignIn() { } Spacer(modifier = Modifier.height(8.dp)) Credential() + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "新用户将自动注册,未添加验证方式的用户将在 7 天后被删除", + modifier = Modifier.alpha(0.6f), + style = MaterialTheme.typography.labelMedium + ) Spacer(modifier = Modifier.height(32.dp)) Box( modifier = Modifier.fillMaxWidth().padding(horizontal = 32.dp), diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 6443dc1..c024ec6 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -2,6 +2,7 @@ kotlin = "1.9.22" kotlinx-atomicfu = "0.23.2" kotlinx-coroutines = "1.8.0" +kotlinx-datetime = "0.5.0" agp = "8.2.2" androidx-core-splashscreen = "1.0.1" androidx-window = "1.2.0" @@ -29,6 +30,7 @@ kotlinx-atomicfu = { module = "org.jetbrains.kotlinx:atomicfu-gradle-plugin", ve kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" } kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "kotlinx-coroutines" } kotlinx-coroutines-swing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-swing", version.ref = "kotlinx-coroutines" } +kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinx-datetime" } androidx-core-splashscreen = { module = "androidx.core:core-splashscreen", version.ref = "androidx-core-splashscreen" } androidx-window = { module = "androidx.window:window", version.ref = "androidx-window" } androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activity-compose" } @@ -54,6 +56,7 @@ ktor-server-resources = { module = "io.ktor:ktor-server-resources", version.ref ktor-server-rate-limit = { module = "io.ktor:ktor-server-rate-limit", version.ref = "ktor" } ktor-server-auth = { module = "io.ktor:ktor-server-auth", version.ref = "ktor" } ktor-server-auth-jwt = { module = "io.ktor:ktor-server-auth-jwt", version.ref = "ktor" } +ktor-server-double-receive = { module = "io.ktor:ktor-server-double-receive", version.ref = "ktor" } ktor-server-content-negotiation = { module = "io.ktor:ktor-server-content-negotiation", version.ref = "ktor" } ktor-server-websockets = { module = "io.ktor:ktor-server-websockets", version.ref = "ktor" } ktor-server-html-builder = { module = "io.ktor:ktor-server-html-builder", version.ref = "ktor" } diff --git a/server/build.gradle.kts b/server/build.gradle.kts index 09f5b9c..5af2368 100644 --- a/server/build.gradle.kts +++ b/server/build.gradle.kts @@ -54,6 +54,7 @@ dependencies { implementation(libs.ktor.server.rate.limit) implementation(libs.ktor.server.auth) implementation(libs.ktor.server.auth.jwt) + implementation(libs.ktor.server.double.receive) implementation(libs.ktor.server.content.negotiation) implementation(libs.ktor.server.websockets) implementation(libs.ktor.server.html.builder) diff --git a/server/src/main/kotlin/xyz/xfqlittlefan/fhraise/Application.kt b/server/src/main/kotlin/xyz/xfqlittlefan/fhraise/Application.kt index b21cf8e..cc59701 100644 --- a/server/src/main/kotlin/xyz/xfqlittlefan/fhraise/Application.kt +++ b/server/src/main/kotlin/xyz/xfqlittlefan/fhraise/Application.kt @@ -27,6 +27,7 @@ import io.ktor.server.config.* import io.ktor.server.engine.* import io.ktor.server.plugins.callid.* import io.ktor.server.plugins.contentnegotiation.* +import io.ktor.server.plugins.doublereceive.* import io.ktor.server.plugins.ratelimit.* import io.ktor.server.resources.* import io.ktor.server.routing.* @@ -37,6 +38,7 @@ import xyz.xfqlittlefan.fhraise.api.apiAuth import xyz.xfqlittlefan.fhraise.api.apiOAuth import xyz.xfqlittlefan.fhraise.api.appAuth import xyz.xfqlittlefan.fhraise.api.registerAppCodeVerification +import xyz.xfqlittlefan.fhraise.auth.cleanupUnverifiedUsersPreDay import xyz.xfqlittlefan.fhraise.models.cleanupVerificationCodes import xyz.xfqlittlefan.fhraise.proxy.proxyKeycloak @@ -58,12 +60,15 @@ fun Application.module() { appAuth() } + install(DoubleReceive) + install(ContentNegotiation) { cbor() } install(WebSockets) { contentConverter = KotlinxWebsocketSerializationConverter(Cbor) } + cleanupUnverifiedUsersPreDay() cleanupVerificationCodes() routing { diff --git a/server/src/main/kotlin/xyz/xfqlittlefan/fhraise/api/Auth.kt b/server/src/main/kotlin/xyz/xfqlittlefan/fhraise/api/Auth.kt index 21bc572..41665d9 100644 --- a/server/src/main/kotlin/xyz/xfqlittlefan/fhraise/api/Auth.kt +++ b/server/src/main/kotlin/xyz/xfqlittlefan/fhraise/api/Auth.kt @@ -209,6 +209,19 @@ private suspend fun RoutingCall.respondCodeVerificationResult( if (verificationValid) { getOrCreateUser(request.parent.credentialType provide token.credential).fold( onSuccess = { user -> + var userNeedsUpdate = false + + if (user.enabled != true) { + user.enabled = true + userNeedsUpdate = true + } + if (request.parent.credentialType == Email && user.emailVerified != true) { + user.emailVerified = true + userNeedsUpdate = true + } + + if (userNeedsUpdate) user.update() + exchangeToken(user.id!!)?.let { respond(Api.Auth.Type.Verify.ResponseBody.Success(it)) } ?: respond(Api.Auth.Type.Verify.ResponseBody.Failure) @@ -228,6 +241,10 @@ private suspend fun RoutingCall.respondPasswordVerificationResult( return } + if (user.getCredentials()?.any { it.type == CredentialRepresentation.CredentialType.Password } != true) { + user.resetPassword { value = body.verification.value }.onSuccess { user.update { enabled = true } } + } + keycloakClient.getTokensByPassword(authClientId, authClientSecret, user.username!!, body.verification)?.let { respond(Api.Auth.Type.Verify.ResponseBody.Success(JwtTokenPair(it.accessToken, it.refreshToken))) } ?: respond(Api.Auth.Type.Verify.ResponseBody.Failure) diff --git a/server/src/main/kotlin/xyz/xfqlittlefan/fhraise/auth/Keycloak.kt b/server/src/main/kotlin/xyz/xfqlittlefan/fhraise/auth/Keycloak.kt index 5706589..46275ff 100644 --- a/server/src/main/kotlin/xyz/xfqlittlefan/fhraise/auth/Keycloak.kt +++ b/server/src/main/kotlin/xyz/xfqlittlefan/fhraise/auth/Keycloak.kt @@ -28,15 +28,24 @@ import io.ktor.client.request.forms.* import io.ktor.client.statement.* import io.ktor.http.* import io.ktor.serialization.kotlinx.json.* +import io.ktor.server.application.* import io.ktor.server.util.* +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.datetime.Clock +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toLocalDateTime import kotlinx.serialization.json.Json import xyz.xfqlittlefan.fhraise.appSecret +import xyz.xfqlittlefan.fhraise.models.CredentialRepresentation import xyz.xfqlittlefan.fhraise.models.UserQuery import xyz.xfqlittlefan.fhraise.models.UserRepresentation import xyz.xfqlittlefan.fhraise.proxy.keycloakHost import xyz.xfqlittlefan.fhraise.proxy.keycloakPort import xyz.xfqlittlefan.fhraise.proxy.keycloakScheme import xyz.xfqlittlefan.fhraise.routes.Api +import kotlin.time.Duration.Companion.days private val authClient = HttpClient { install(ContentNegotiation) { @@ -78,13 +87,16 @@ private var currentTokens: BearerTokens? = null private const val phoneNumberAttribute = "phoneNumber" -private suspend fun loadTokens(): BearerTokens? = currentTokens ?: authClient.getTokensByPassword( +private suspend fun loadTokens() = + currentTokens ?: getTokens()?.let { BearerTokens(it.accessToken, it.refreshToken) }?.also { currentTokens = it } + +private suspend fun getTokens() = authClient.getTokensByPassword( adminClientId, adminClientSecret, adminUsername, Api.Auth.Type.Verify.RequestBody.Verification(adminPassword) -)?.let { BearerTokens(it.accessToken, it.refreshToken) }?.also { currentTokens = it } +) -private suspend fun RefreshTokensParams.refreshTokens(): BearerTokens? = +private suspend fun RefreshTokensParams.refreshTokens() = (oldTokens?.refreshToken ?: currentTokens?.refreshToken)?.let { - authClient.refreshTokens(adminClientId, adminClientSecret, it) + authClient.refreshTokens(adminClientId, adminClientSecret, it) ?: getTokens() }?.let { BearerTokens(it.accessToken, it.refreshToken) }?.also { currentTokens = it } suspend fun exchangeToken(userId: String) = currentTokens?.let { @@ -122,13 +134,23 @@ suspend fun getUser(block: UserQuery.() -> Unit) = adminClient.get(adminUrl { } }).let { when { - it.status.isSuccess() -> it.body>().first() + it.status.isSuccess() -> it.body>().firstOrNull() + else -> null + } +}?.let { + adminClient.get(adminUrl { + appendPathSegments("users", it.id!!) + }) +}?.let { + when { + it.status.isSuccess() -> it.body() else -> null } } private suspend fun createUser(query: UserQuery) = adminClient.post { url(adminUrl { appendPathSegments("users") }) + contentType(ContentType.Application.Json) setBody(UserRepresentation().apply { username = query.generatedUsername email = query.email @@ -136,6 +158,67 @@ private suspend fun createUser(query: UserQuery) = adminClient.post { }) }.let { if (it.status.isSuccess()) Result.success(Unit) else Result.failure(Exception(it.bodyAsText())) } +suspend fun UserRepresentation.getCredentials() = adminClient.get(adminUrl { + appendPathSegments("users", id!!, "credentials") +}).let { + when { + it.status.isSuccess() -> it.body>() + else -> null + } +} + +suspend fun UserRepresentation.update(block: UserRepresentation.() -> Unit = {}) = + this@update.apply(block).let { newUser -> + adminClient.put { + url(adminUrl { appendPathSegments("users", id!!) }) + contentType(ContentType.Application.Json) + setBody(newUser) + }.let { if (it.status.isSuccess()) Result.success(newUser) else Result.failure(Exception(it.bodyAsText())) } + } + +suspend fun UserRepresentation.resetPassword(block: CredentialRepresentation.() -> Unit) = adminClient.put { + url(adminUrl { appendPathSegments("users", id!!, "reset-password") }) + contentType(ContentType.Application.Json) + setBody(CredentialRepresentation(type = CredentialRepresentation.CredentialType.Password).apply(block)) +}.let { if (it.status.isSuccess()) Result.success(Unit) else Result.failure(Exception(it.bodyAsText())) } + +private suspend fun UserRepresentation.delete() = adminClient.delete { + url(adminUrl { appendPathSegments("users", id!!) }) +}.let { if (it.status.isSuccess()) Result.success(Unit) else Result.failure(Exception(it.bodyAsText())) } + +fun Application.cleanupUnverifiedUsersPreDay() { + launch(Dispatchers.IO) { + while (true) { + log.trace("Cleaning up unverified users") + getUnverifiedUsers()?.filter { it.createdAt!! + 7.days < Clock.System.now() }?.forEach { + log.trace("Deleting unverified user ${it.username} (id: ${it.id})") + it.delete() + } + log.trace( + "Finished cleaning up unverified users, next time cleaning up: {}", + (Clock.System.now() + 1.days).toLocalDateTime(TimeZone.currentSystemDefault()) + ) + delay(1.days) + } + } +} + +private suspend fun getUnverifiedUsers(first: Int = 0): List? = adminClient.get(adminUrl { + appendPathSegments("users") + parameters.apply { + append("first", first.toString()) + append("max", "100") + append("enabled", "false") + } +}).let { + when { + it.status.isSuccess() -> it.body>() + else -> null + } +}?.run { + if (size == 100) getUnverifiedUsers(first + 100)?.plus(this) else this +} + infix fun Api.Auth.Type.CredentialType.provide(credential: String): UserQuery.() -> Unit = { when (this@provide) { Api.Auth.Type.CredentialType.Username -> username = credential diff --git a/server/src/main/kotlin/xyz/xfqlittlefan/fhraise/auth/OAuth.kt b/server/src/main/kotlin/xyz/xfqlittlefan/fhraise/auth/OAuth.kt index 7659cb0..4d0ea41 100644 --- a/server/src/main/kotlin/xyz/xfqlittlefan/fhraise/auth/OAuth.kt +++ b/server/src/main/kotlin/xyz/xfqlittlefan/fhraise/auth/OAuth.kt @@ -21,6 +21,7 @@ package xyz.xfqlittlefan.fhraise.auth import io.ktor.client.* import io.ktor.client.call.* import io.ktor.client.request.forms.* +import io.ktor.client.statement.* import io.ktor.http.* import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @@ -32,36 +33,36 @@ suspend fun HttpClient.getTokensByPassword( clientSecret: String, username: String, verification: Api.Auth.Type.Verify.RequestBody.Verification -) = runCatching { - submitForm( - url = keycloakTokenUrl, - formParameters = parameters { - append("client_id", clientId) - append("client_secret", clientSecret) - append("grant_type", "password") - append("username", username) - append("password", verification.value) - verification.otp?.let { if (it.isNotBlank()) append("totp", it) } - }, - ).body() -}.getOrElse { - logger.error("Failed to get tokens by password", it) - null +) = submitForm( + url = keycloakTokenUrl, + formParameters = parameters { + append("client_id", clientId) + append("client_secret", clientSecret) + append("grant_type", "password") + append("username", username) + append("password", verification.value) + verification.otp?.let { if (it.isNotBlank()) append("totp", it) } + }, +).let { response -> + runCatching { response.body() }.getOrElse { + logger.error("Failed to get tokens by password", Exception(response.bodyAsText(), it)) + null + } } -suspend fun HttpClient.refreshTokens(clientId: String, clientSecret: String, refreshToken: String) = runCatching { - submitForm( - url = keycloakTokenUrl, - formParameters = parameters { - append("client_id", clientId) - append("client_secret", clientSecret) - append("grant_type", "refresh_token") - append("refresh_token", refreshToken) - }, - ).body() -}.getOrElse { - logger.error("Failed to refresh tokens", it) - null +suspend fun HttpClient.refreshTokens(clientId: String, clientSecret: String, refreshToken: String) = submitForm( + url = keycloakTokenUrl, + formParameters = parameters { + append("client_id", clientId) + append("client_secret", clientSecret) + append("grant_type", "refresh_token") + append("refresh_token", refreshToken) + }, +).let { response -> + runCatching { response.body() }.getOrElse { + logger.error("Failed to refresh tokens", Exception(response.bodyAsText(), it)) + null + } } @Serializable diff --git a/server/src/main/kotlin/xyz/xfqlittlefan/fhraise/models/Representations.kt b/server/src/main/kotlin/xyz/xfqlittlefan/fhraise/models/Representations.kt new file mode 100644 index 0000000..d5ef9d1 --- /dev/null +++ b/server/src/main/kotlin/xyz/xfqlittlefan/fhraise/models/Representations.kt @@ -0,0 +1,123 @@ +/* + * This file is part of Fhraise. + * Copyright (c) 2024 HSAS Foodies. All Rights Reserved. + * + * Fhraise is free software: you can redistribute it and/or modify it under + * the terms of the GNU General Public License as published by the Free + * Software Foundation, either version 3 of the License, or (at your option) + * any later version. + * + * Fhraise is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * for more details. + * + * You should have received a copy of the GNU General Public License along + * with Fhraise. If not, see . + */ + +package xyz.xfqlittlefan.fhraise.models + +import kotlinx.datetime.Instant +import kotlinx.serialization.Contextual +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import xyz.xfqlittlefan.fhraise.serializers.InstantEpochMillisecondsSerializer + +@Serializable +data class UserRepresentation( + var id: String? = null, + var username: String? = null, + var firstName: String? = null, + var lastName: String? = null, + var email: String? = null, + var emailVerified: Boolean? = null, + var attributes: Map>? = null, + var userProfileMetadata: UserProfileMetadata? = null, + var self: String? = null, + var origin: String? = null, + @SerialName("createdTimestamp") @Serializable(InstantEpochMillisecondsSerializer::class) var createdAt: Instant? = null, + var enabled: Boolean? = null, + var totp: Boolean? = null, + var federationLink: String? = null, + var serviceAccountClientId: String? = null, + var credentials: List? = null, + var disableableCredentialTypes: Set? = null, + var requiredActions: List? = null, + var federatedIdentities: List? = null, + var realmRoles: List? = null, + var clientRoles: Map>? = null, + var clientConsents: List? = null, + var notBefore: Int? = null, + var groups: List? = null, + var access: Map? = null +) + +@Serializable +data class UserProfileMetadata( + var attributes: List? = null, + var groups: List? = null +) + +@Serializable +data class UserProfileAttributeMetadata( + var name: String? = null, + var displayName: String? = null, + var required: Boolean? = null, + var readOnly: Boolean? = null, + var annotations: Map? = null, + var validators: Map>? = null, + var group: String? = null +) + +@Serializable +data class UserProfileAttributeGroupMetadata( + var name: String? = null, + var displayHeader: String? = null, + var displayDescription: String? = null, + var annotations: Map? = null +) + +@Serializable +data class CredentialRepresentation( + var id: String? = null, + var type: CredentialType? = null, + var userLabel: String? = null, + var createdDate: Long? = null, + var secretData: String? = null, + var credentialData: String? = null, + var priority: Int? = null, + var value: String? = null, + var temporary: Boolean? = null +) { + @Serializable + enum class CredentialType { + @SerialName("secret") + Secret, + + @SerialName("password") + Password, + + @SerialName("totp") + Totp, + + @SerialName("hotp") + Hotp, + + @SerialName("kerberos") + Kerberos + } +} + +@Serializable +data class FederatedIdentityRepresentation( + var identityProvider: String? = null, var userId: String? = null, var userName: String? = null +) + +@Serializable +data class UserConsentRepresentation( + var clientId: String? = null, + var createDate: Long? = null, + var grantedClientScopes: List? = null, + var lastUpdatedDate: Long? = null +) diff --git a/server/src/main/kotlin/xyz/xfqlittlefan/fhraise/models/User.kt b/server/src/main/kotlin/xyz/xfqlittlefan/fhraise/models/User.kt index 76d4ba9..9d7ef0a 100644 --- a/server/src/main/kotlin/xyz/xfqlittlefan/fhraise/models/User.kt +++ b/server/src/main/kotlin/xyz/xfqlittlefan/fhraise/models/User.kt @@ -18,67 +18,7 @@ package xyz.xfqlittlefan.fhraise.models -import kotlinx.serialization.Serializable - data class UserQuery(var username: String? = null, var phoneNumber: String? = null, var email: String? = null) { val generatedUsername get() = username ?: email ?: phoneNumber ?: error("No username, phone number, or email provided") } - -@Serializable -data class UserRepresentation( - var access: Map? = null, - var attributes: Map>? = null, - var clientConsents: List? = null, - var clientRoles: Map>? = null, - var createdTimestamp: Long? = null, - var credentials: List? = null, - var disableableCredentialTypes: List? = null, - var email: String? = null, - var emailVerified: Boolean? = null, - var enabled: Boolean? = null, - var federatedIdentities: List? = null, - var federationLink: String? = null, - var firstName: String? = null, - var groups: List? = null, - var id: String? = null, - var lastName: String? = null, - var notBefore: Int? = null, - var origin: String? = null, - var realmRoles: List? = null, - var requiredActions: List? = null, - var self: String? = null, - var serviceAccountClientId: String? = null, - var totp: Boolean? = null, - var username: String? = null -) - -@Serializable -data class UserConsentRepresentation( - var clientId: String? = null, - var createDate: Long? = null, - var grantedClientScopes: List? = null, - var lastUpdatedDate: Long? = null -) - -@Serializable -data class CredentialRepresentation( - var algorithm: String? = null, - var config: Map? = null, - var counter: Int? = null, - var createdDate: Long? = null, - var device: String? = null, - var digits: Int? = null, - var hashIterations: Int? = null, - var hashedSaltedvarue: String? = null, - var period: Int? = null, - var salt: String? = null, - var temporary: Boolean? = null, - var type: String? = null, - var varue: String? = null -) - -@Serializable -data class FederatedIdentityRepresentation( - var identityProvider: String? = null, var userId: String? = null, var userName: String? = null -) diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts index c2a1ace..431d81a 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -43,6 +43,7 @@ kotlin { val commonMain by getting { dependencies { implementation(libs.kotlinx.coroutines.core) + implementation(libs.kotlinx.datetime) implementation(libs.ktor.client.core) implementation(libs.ktor.http) implementation(libs.ktor.resources) diff --git a/shared/src/commonMain/kotlin/xyz/xfqlittlefan/fhraise/serializers/InstantSerializers.kt b/shared/src/commonMain/kotlin/xyz/xfqlittlefan/fhraise/serializers/InstantSerializers.kt new file mode 100644 index 0000000..8ad5c0e --- /dev/null +++ b/shared/src/commonMain/kotlin/xyz/xfqlittlefan/fhraise/serializers/InstantSerializers.kt @@ -0,0 +1,39 @@ +/* + * This file is part of Fhraise. + * Copyright (c) 2024 HSAS Foodies. All Rights Reserved. + * + * Fhraise is free software: you can redistribute it and/or modify it under + * the terms of the GNU General Public License as published by the Free + * Software Foundation, either version 3 of the License, or (at your option) + * any later version. + * + * Fhraise is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * for more details. + * + * You should have received a copy of the GNU General Public License along + * with Fhraise. If not, see . + */ + +package xyz.xfqlittlefan.fhraise.serializers + +import kotlinx.datetime.Instant +import kotlinx.serialization.KSerializer +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder + +object InstantEpochMillisecondsSerializer : KSerializer { + override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("Instant", PrimitiveKind.LONG) + + override fun serialize(encoder: Encoder, value: Instant) { + encoder.encodeLong(value.toEpochMilliseconds()) + } + + override fun deserialize(decoder: Decoder): Instant { + return Instant.fromEpochMilliseconds(decoder.decodeLong()) + } +}