Skip to content

Commit

Permalink
feat: 自动注册及游客清理
Browse files Browse the repository at this point in the history
  • Loading branch information
xfqwdsj committed Feb 17, 2024
1 parent 4da7fc1 commit 10639f2
Show file tree
Hide file tree
Showing 14 changed files with 361 additions and 110 deletions.
2 changes: 1 addition & 1 deletion build-number.properties
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,4 @@
# You should have received a copy of the GNU General Public License along
# with Fhraise. If not, see <https://www.gnu.org/licenses/>.
#
buildNumber=45
buildNumber=46
7 changes: 7 additions & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -204,7 +205,7 @@ interface SignInComponent : AppComponentContext {

fun onMicrosoftSignIn()

fun enter(tokenPair: JwtTokenPair? = null)
fun enter()

val enterAction: KeyboardActionScope.() -> Unit
get() = {
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
}
}

Expand All @@ -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
)
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
3 changes: 3 additions & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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" }
Expand All @@ -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" }
Expand Down
1 change: 1 addition & 0 deletions server/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.*
Expand All @@ -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

Expand All @@ -58,12 +60,15 @@ fun Application.module() {
appAuth()
}

install(DoubleReceive)

install(ContentNegotiation) { cbor() }

install(WebSockets) {
contentConverter = KotlinxWebsocketSerializationConverter(Cbor)
}

cleanupUnverifiedUsersPreDay()
cleanupVerificationCodes()

routing {
Expand Down
17 changes: 17 additions & 0 deletions server/src/main/kotlin/xyz/xfqlittlefan/fhraise/api/Auth.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand Down
93 changes: 88 additions & 5 deletions server/src/main/kotlin/xyz/xfqlittlefan/fhraise/auth/Keycloak.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -122,20 +134,91 @@ suspend fun getUser(block: UserQuery.() -> Unit) = adminClient.get(adminUrl {
}
}).let {
when {
it.status.isSuccess() -> it.body<List<UserRepresentation>>().first()
it.status.isSuccess() -> it.body<List<UserRepresentation>>().firstOrNull()
else -> null
}
}?.let {
adminClient.get(adminUrl {
appendPathSegments("users", it.id!!)
})
}?.let {
when {
it.status.isSuccess() -> it.body<UserRepresentation>()
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
query.phoneNumber?.let { attributes = mapOf(phoneNumberAttribute to listOf(it)) }
})
}.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<List<CredentialRepresentation>>()
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<UserRepresentation>? = adminClient.get(adminUrl {
appendPathSegments("users")
parameters.apply {
append("first", first.toString())
append("max", "100")
append("enabled", "false")
}
}).let {
when {
it.status.isSuccess() -> it.body<List<UserRepresentation>>()
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
Expand Down
Loading

0 comments on commit 10639f2

Please sign in to comment.