From 80b1ac8f76120df26533cbfe6a42f3ef60bca741 Mon Sep 17 00:00:00 2001
From: ihateblueb
Date: Tue, 6 Jan 2026 23:29:26 -0500
Subject: [PATCH 1/4] Add routes for TOTP registering, login validation, and
database column
---
build.gradle.kts | 4 +-
.../common/model/request/LoginRequest.kt | 12 ++++
.../request/RegisterTotpConfirmRequest.kt | 10 +++
.../model/response/RegisterTotpResponse.kt | 10 +++
settings.gradle.kts | 2 +-
.../aster/db/entity/UserPrivateEntity.kt | 1 +
.../remlit/aster/db/table/UserPrivateTable.kt | 1 +
.../aster/event/user/UserTotpRegisterEvent.kt | 10 +++
.../event/user/UserTotpUnregisterEvent.kt | 10 +++
.../remlit/aster/route/api/LoginRoutes.kt | 17 +++--
.../site/remlit/aster/route/api/UserRoutes.kt | 29 +++++++-
.../site/remlit/aster/service/AuthService.kt | 69 +++++++++++++++++++
12 files changed, 163 insertions(+), 12 deletions(-)
create mode 100644 common/src/commonMain/kotlin/site/remlit/aster/common/model/request/LoginRequest.kt
create mode 100644 common/src/commonMain/kotlin/site/remlit/aster/common/model/request/RegisterTotpConfirmRequest.kt
create mode 100644 common/src/commonMain/kotlin/site/remlit/aster/common/model/response/RegisterTotpResponse.kt
create mode 100644 src/main/kotlin/site/remlit/aster/event/user/UserTotpRegisterEvent.kt
create mode 100644 src/main/kotlin/site/remlit/aster/event/user/UserTotpUnregisterEvent.kt
diff --git a/build.gradle.kts b/build.gradle.kts
index 2e8e4afa..74c0db03 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -76,9 +76,11 @@ dependencies {
// authentication
implementation("at.favre.lib:bcrypt:0.10.2")
+ implementation("com.j256.two-factor-auth:two-factor-auth:1.3")
+ implementation("org.wso2.orbit.webauthn4j:webauthn4j:0.21.0.wso2v1")
// misc
- implementation("org.jetbrains.kotlin:kotlin-reflect:2.2.21")
+ implementation("org.jetbrains.kotlin:kotlin-reflect:2.3.0")
implementation("com.googlecode.owasp-java-html-sanitizer:owasp-java-html-sanitizer:20260101.1")
implementation("site.remlit:effekt:0.2.1")
diff --git a/common/src/commonMain/kotlin/site/remlit/aster/common/model/request/LoginRequest.kt b/common/src/commonMain/kotlin/site/remlit/aster/common/model/request/LoginRequest.kt
new file mode 100644
index 00000000..185aa97f
--- /dev/null
+++ b/common/src/commonMain/kotlin/site/remlit/aster/common/model/request/LoginRequest.kt
@@ -0,0 +1,12 @@
+package site.remlit.aster.common.model.request
+
+import kotlinx.serialization.Serializable
+import kotlin.js.JsExport
+
+@JsExport
+@Serializable
+data class LoginRequest(
+ val username: String,
+ val password: String,
+ val totp: Int? = null,
+)
diff --git a/common/src/commonMain/kotlin/site/remlit/aster/common/model/request/RegisterTotpConfirmRequest.kt b/common/src/commonMain/kotlin/site/remlit/aster/common/model/request/RegisterTotpConfirmRequest.kt
new file mode 100644
index 00000000..637a66bf
--- /dev/null
+++ b/common/src/commonMain/kotlin/site/remlit/aster/common/model/request/RegisterTotpConfirmRequest.kt
@@ -0,0 +1,10 @@
+package site.remlit.aster.common.model.request
+
+import kotlinx.serialization.Serializable
+import kotlin.js.JsExport
+
+@JsExport
+@Serializable
+data class RegisterTotpConfirmRequest(
+ val code: Int,
+)
diff --git a/common/src/commonMain/kotlin/site/remlit/aster/common/model/response/RegisterTotpResponse.kt b/common/src/commonMain/kotlin/site/remlit/aster/common/model/response/RegisterTotpResponse.kt
new file mode 100644
index 00000000..aa15c139
--- /dev/null
+++ b/common/src/commonMain/kotlin/site/remlit/aster/common/model/response/RegisterTotpResponse.kt
@@ -0,0 +1,10 @@
+package site.remlit.aster.common.model.response
+
+import kotlinx.serialization.Serializable
+import kotlin.js.JsExport
+
+@JsExport
+@Serializable
+data class RegisterTotpResponse(
+ val secret: String,
+)
diff --git a/settings.gradle.kts b/settings.gradle.kts
index 61c9ac0e..53551eb6 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -1,5 +1,5 @@
rootProject.name = "aster"
-gradle.extra.set("rootVersion", "2026.1.3.0-SNAPSHOT")
+gradle.extra.set("rootVersion", "2026.1.4.0-SNAPSHOT")
gradle.extra.set("repository", "https://github.com/aster-soc/aster")
gradle.extra.set("issueTracker", "https://youtrack.remlit.site/projects/AS/issues")
diff --git a/src/main/kotlin/site/remlit/aster/db/entity/UserPrivateEntity.kt b/src/main/kotlin/site/remlit/aster/db/entity/UserPrivateEntity.kt
index b4eef100..0fbc8857 100644
--- a/src/main/kotlin/site/remlit/aster/db/entity/UserPrivateEntity.kt
+++ b/src/main/kotlin/site/remlit/aster/db/entity/UserPrivateEntity.kt
@@ -8,6 +8,7 @@ import site.remlit.aster.db.table.UserPrivateTable
class UserPrivateEntity(id: EntityID) : Entity(id) {
companion object : EntityClass(UserPrivateTable)
+ var totpSecret by UserPrivateTable.totpSecret
var password by UserPrivateTable.password
var privateKey by UserPrivateTable.privateKey
}
diff --git a/src/main/kotlin/site/remlit/aster/db/table/UserPrivateTable.kt b/src/main/kotlin/site/remlit/aster/db/table/UserPrivateTable.kt
index 2121b21d..06af1713 100644
--- a/src/main/kotlin/site/remlit/aster/db/table/UserPrivateTable.kt
+++ b/src/main/kotlin/site/remlit/aster/db/table/UserPrivateTable.kt
@@ -9,6 +9,7 @@ object UserPrivateTable : IdTable("user_private") {
override val id = varchar("id", length = TEXT_TINY)
.uniqueIndex().entityId()
+ val totpSecret = varchar("totpSecret", length = TEXT_SMALL).nullable()
val password = varchar("password", length = TEXT_SMALL)
val privateKey = varchar("privateKey", length = TEXT_MEDIUM)
diff --git a/src/main/kotlin/site/remlit/aster/event/user/UserTotpRegisterEvent.kt b/src/main/kotlin/site/remlit/aster/event/user/UserTotpRegisterEvent.kt
new file mode 100644
index 00000000..b7605a40
--- /dev/null
+++ b/src/main/kotlin/site/remlit/aster/event/user/UserTotpRegisterEvent.kt
@@ -0,0 +1,10 @@
+package site.remlit.aster.event.user
+
+import site.remlit.aster.common.model.User
+
+/**
+ * Event fired for when a user enables time-based one time passwords
+ *
+ * @since 2026.1.4.0-SNAPSHOT
+ * */
+class UserTotpRegisterEvent(user: User) : UserEvent(user)
diff --git a/src/main/kotlin/site/remlit/aster/event/user/UserTotpUnregisterEvent.kt b/src/main/kotlin/site/remlit/aster/event/user/UserTotpUnregisterEvent.kt
new file mode 100644
index 00000000..89ea957a
--- /dev/null
+++ b/src/main/kotlin/site/remlit/aster/event/user/UserTotpUnregisterEvent.kt
@@ -0,0 +1,10 @@
+package site.remlit.aster.event.user
+
+import site.remlit.aster.common.model.User
+
+/**
+ * Event fired for when a user removes time-based one time passwords
+ *
+ * @since 2026.1.4.0-SNAPSHOT
+ * */
+class UserTotpUnregisterEvent(user: User) : UserEvent(user)
diff --git a/src/main/kotlin/site/remlit/aster/route/api/LoginRoutes.kt b/src/main/kotlin/site/remlit/aster/route/api/LoginRoutes.kt
index f5f2be7f..15569aed 100644
--- a/src/main/kotlin/site/remlit/aster/route/api/LoginRoutes.kt
+++ b/src/main/kotlin/site/remlit/aster/route/api/LoginRoutes.kt
@@ -5,10 +5,12 @@ import io.ktor.http.*
import io.ktor.server.request.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
+import kotlinx.html.A
import kotlinx.serialization.Serializable
import org.jetbrains.exposed.v1.core.and
import org.jetbrains.exposed.v1.core.eq
import site.remlit.aster.common.model.User
+import site.remlit.aster.common.model.request.LoginRequest
import site.remlit.aster.common.model.response.AuthResponse
import site.remlit.aster.db.table.UserTable
import site.remlit.aster.model.ApiException
@@ -18,16 +20,11 @@ import site.remlit.aster.service.UserService
import site.remlit.aster.util.model.fromEntity
internal object LoginRoutes {
- @Serializable
- data class LoginBody(
- val username: String,
- val password: String
- )
fun register() =
RouteRegistry.registerRoute {
post("/api/login") {
- val body = call.receive()
+ val body = call.receive()
if (body.username.isBlank())
throw ApiException(HttpStatusCode.BadRequest, "Username required")
@@ -43,7 +40,7 @@ internal object LoginRoutes {
)
val userPrivate = UserService.getPrivateById(user.id)
- ?: throw ApiException(HttpStatusCode.BadRequest, "User's private table not found")
+ ?: throw ApiException(HttpStatusCode.BadRequest, "User not found")
val passwordValid =
BCrypt.verifyer().verify(body.password.toCharArray(), userPrivate.password.toCharArray())
@@ -51,6 +48,12 @@ internal object LoginRoutes {
if (!passwordValid.verified)
throw ApiException(HttpStatusCode.BadRequest, "Incorrect password")
+ if (userPrivate.totpSecret != null && body.totp == null)
+ throw ApiException(HttpStatusCode.BadRequest, "One time password required")
+
+ if (userPrivate.totpSecret != null && !AuthService.confirmTotp(user.id, body.totp!!))
+ throw ApiException(HttpStatusCode.BadRequest, "One time password incorrect")
+
val token = AuthService.registerToken(user.id)
call.respond(AuthResponse(token, user))
diff --git a/src/main/kotlin/site/remlit/aster/route/api/UserRoutes.kt b/src/main/kotlin/site/remlit/aster/route/api/UserRoutes.kt
index 03961525..9f86f578 100644
--- a/src/main/kotlin/site/remlit/aster/route/api/UserRoutes.kt
+++ b/src/main/kotlin/site/remlit/aster/route/api/UserRoutes.kt
@@ -5,15 +5,15 @@ import io.ktor.server.request.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import io.ktor.server.util.*
-import org.jetbrains.exposed.v1.core.and
-import org.jetbrains.exposed.v1.core.eq
import site.remlit.aster.common.model.User
import site.remlit.aster.common.model.generated.PartialUser
+import site.remlit.aster.common.model.request.RegisterTotpConfirmRequest
import site.remlit.aster.common.model.request.ReportRequest
-import site.remlit.aster.db.table.UserTable
+import site.remlit.aster.common.model.response.RegisterTotpResponse
import site.remlit.aster.model.ApiException
import site.remlit.aster.model.Configuration
import site.remlit.aster.registry.RouteRegistry
+import site.remlit.aster.service.AuthService
import site.remlit.aster.service.NotificationService
import site.remlit.aster.service.RelationshipService
import site.remlit.aster.service.ReportService
@@ -195,6 +195,29 @@ internal object UserRoutes {
)
)
}
+
+ post("/api/user/register-totp") {
+ val authenticatedUser = call.attributes[authenticatedUserKey]
+ call.respond(RegisterTotpResponse(
+ AuthService.registerTotp(authenticatedUser.id.toString())
+ ))
+ }
+
+ post("/api/user/register-totp/confirm") {
+ val authenticatedUser = call.attributes[authenticatedUserKey]
+ val body = call.receive()
+
+ if (!AuthService.confirmTotp(authenticatedUser.id.toString(), body.code))
+ throw ApiException(HttpStatusCode.Forbidden, "One time password incorrect")
+
+ call.respond(HttpStatusCode.OK)
+ }
+
+ post("/api/user/unregister-totp") {
+ val authenticatedUser = call.attributes[authenticatedUserKey]
+ AuthService.removeTotp(authenticatedUser.id.toString())
+ call.respond(HttpStatusCode.OK)
+ }
}
}
}
diff --git a/src/main/kotlin/site/remlit/aster/service/AuthService.kt b/src/main/kotlin/site/remlit/aster/service/AuthService.kt
index 01e4e664..f3bffdd6 100644
--- a/src/main/kotlin/site/remlit/aster/service/AuthService.kt
+++ b/src/main/kotlin/site/remlit/aster/service/AuthService.kt
@@ -1,14 +1,23 @@
package site.remlit.aster.service
+import com.j256.twofactorauth.TimeBasedOneTimePasswordUtil
+import kotlinx.html.S
import org.jetbrains.exposed.v1.core.Op
import org.jetbrains.exposed.v1.core.eq
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
+import site.remlit.aster.common.model.User
import site.remlit.aster.db.entity.AuthEntity
import site.remlit.aster.db.entity.UserEntity
+import site.remlit.aster.db.entity.UserPrivateEntity
import site.remlit.aster.db.table.AuthTable
+import site.remlit.aster.db.table.UserPrivateTable
import site.remlit.aster.db.table.UserTable
import site.remlit.aster.event.auth.AuthTokenCreateEvent
+import site.remlit.aster.event.user.UserTotpRegisterEvent
+import site.remlit.aster.event.user.UserTotpUnregisterEvent
+import site.remlit.aster.model.Configuration
import site.remlit.aster.model.Service
+import site.remlit.aster.util.model.fromEntity
/**
* Service for managing user authentication.
@@ -87,4 +96,64 @@ object AuthService : Service {
return generatedToken
}
+
+ /**
+ * Register time-based one time passwords for a user
+ *
+ * @param user ID of user
+ *
+ * @return Generated secret
+ * */
+ @JvmStatic
+ fun registerTotp(user: String): String {
+ val user = UserService.getById(user)
+ ?: throw IllegalArgumentException("User not found")
+
+ val secret = TimeBasedOneTimePasswordUtil.generateBase32Secret()
+
+ transaction {
+ UserPrivateEntity.findByIdAndUpdate(user.id.toString()) {
+ it.totpSecret = secret
+ }
+ }
+
+ UserTotpRegisterEvent(User.fromEntity(user)).call()
+
+ return secret
+ }
+
+ /**
+ * Determines if a time-based one time password is valid or not
+ *
+ * @param user ID of user
+ * @param code User submitted one time password
+ *
+ * @return If one time password is valid
+ * */
+ @JvmStatic
+ fun confirmTotp(user: String, code: Int): Boolean {
+ val private = UserService.getPrivateById(user)
+ ?: throw IllegalArgumentException("User not found")
+
+ return TimeBasedOneTimePasswordUtil.generateCurrentNumber(private.totpSecret) == code
+ }
+
+ /**
+ * Removes the time-based one time passwords for a user
+ *
+ * @param user ID of user
+ * */
+ @JvmStatic
+ fun removeTotp(user: String) {
+ val user = UserService.getById(user)
+ ?: throw IllegalArgumentException("User not found")
+
+ transaction {
+ UserPrivateEntity.findByIdAndUpdate(user.id.toString()) {
+ it.totpSecret = null
+ }
+ }
+
+ UserTotpUnregisterEvent(User.fromEntity(user)).call()
+ }
}
From 66b55b4351172a3f1acbdc6b818e31200c2cd391 Mon Sep 17 00:00:00 2001
From: ihateblueb
Date: Fri, 9 Jan 2026 13:31:47 -0500
Subject: [PATCH 2/4] Totp verification routes and Api.kt methods
---
build.gradle.kts | 1 -
.../model/request/LoginRequirementsRequest.kt | 10 +++++
...onfirmRequest.kt => TotpConfirmRequest.kt} | 2 +-
.../response/LoginRequirementsResponse.kt | 10 +++++
.../site/remlit/aster/common/api/Api.kt | 38 ++++++++++++++++++-
.../remlit/aster/route/api/LoginRoutes.kt | 14 +++++++
.../site/remlit/aster/route/api/UserRoutes.kt | 10 ++---
7 files changed, 76 insertions(+), 9 deletions(-)
create mode 100644 common/src/commonMain/kotlin/site/remlit/aster/common/model/request/LoginRequirementsRequest.kt
rename common/src/commonMain/kotlin/site/remlit/aster/common/model/request/{RegisterTotpConfirmRequest.kt => TotpConfirmRequest.kt} (80%)
create mode 100644 common/src/commonMain/kotlin/site/remlit/aster/common/model/response/LoginRequirementsResponse.kt
diff --git a/build.gradle.kts b/build.gradle.kts
index 74c0db03..fa7bee0b 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -77,7 +77,6 @@ dependencies {
// authentication
implementation("at.favre.lib:bcrypt:0.10.2")
implementation("com.j256.two-factor-auth:two-factor-auth:1.3")
- implementation("org.wso2.orbit.webauthn4j:webauthn4j:0.21.0.wso2v1")
// misc
implementation("org.jetbrains.kotlin:kotlin-reflect:2.3.0")
diff --git a/common/src/commonMain/kotlin/site/remlit/aster/common/model/request/LoginRequirementsRequest.kt b/common/src/commonMain/kotlin/site/remlit/aster/common/model/request/LoginRequirementsRequest.kt
new file mode 100644
index 00000000..376dd073
--- /dev/null
+++ b/common/src/commonMain/kotlin/site/remlit/aster/common/model/request/LoginRequirementsRequest.kt
@@ -0,0 +1,10 @@
+package site.remlit.aster.common.model.request
+
+import kotlinx.serialization.Serializable
+import kotlin.js.JsExport
+
+@JsExport
+@Serializable
+data class LoginRequirementsRequest(
+ val username: String,
+)
diff --git a/common/src/commonMain/kotlin/site/remlit/aster/common/model/request/RegisterTotpConfirmRequest.kt b/common/src/commonMain/kotlin/site/remlit/aster/common/model/request/TotpConfirmRequest.kt
similarity index 80%
rename from common/src/commonMain/kotlin/site/remlit/aster/common/model/request/RegisterTotpConfirmRequest.kt
rename to common/src/commonMain/kotlin/site/remlit/aster/common/model/request/TotpConfirmRequest.kt
index 637a66bf..b5df6f9a 100644
--- a/common/src/commonMain/kotlin/site/remlit/aster/common/model/request/RegisterTotpConfirmRequest.kt
+++ b/common/src/commonMain/kotlin/site/remlit/aster/common/model/request/TotpConfirmRequest.kt
@@ -5,6 +5,6 @@ import kotlin.js.JsExport
@JsExport
@Serializable
-data class RegisterTotpConfirmRequest(
+data class TotpConfirmRequest(
val code: Int,
)
diff --git a/common/src/commonMain/kotlin/site/remlit/aster/common/model/response/LoginRequirementsResponse.kt b/common/src/commonMain/kotlin/site/remlit/aster/common/model/response/LoginRequirementsResponse.kt
new file mode 100644
index 00000000..0544494e
--- /dev/null
+++ b/common/src/commonMain/kotlin/site/remlit/aster/common/model/response/LoginRequirementsResponse.kt
@@ -0,0 +1,10 @@
+package site.remlit.aster.common.model.response
+
+import kotlinx.serialization.Serializable
+import kotlin.js.JsExport
+
+@JsExport
+@Serializable
+data class LoginRequirementsResponse(
+ val totp: Boolean
+)
diff --git a/common/src/jsMain/kotlin/site/remlit/aster/common/api/Api.kt b/common/src/jsMain/kotlin/site/remlit/aster/common/api/Api.kt
index a3272d3c..049e588a 100644
--- a/common/src/jsMain/kotlin/site/remlit/aster/common/api/Api.kt
+++ b/common/src/jsMain/kotlin/site/remlit/aster/common/api/Api.kt
@@ -10,6 +10,8 @@ import site.remlit.aster.common.model.User
import site.remlit.aster.common.model.Visibility
import site.remlit.aster.common.model.request.CreateNoteRequest
import site.remlit.aster.common.model.response.AuthResponse
+import site.remlit.aster.common.model.response.LoginRequirementsResponse
+import site.remlit.aster.common.model.response.RegisterTotpResponse
import site.remlit.aster.common.util.Https
import site.remlit.aster.common.util.toObject
import kotlin.js.Promise
@@ -32,16 +34,27 @@ class Api {
).unsafeCast>()
@JsStatic
- fun login(username: String, password: String) =
+ fun login(username: String, password: String, totp: Int? = null) =
Https.post(
"/api/login",
false,
mapOf(
"username" to username,
- "password" to password
+ "password" to password,
+ "totp" to totp
).toObject()
).unsafeCast>()
+ @JsStatic
+ fun getLoginRequirements(username: String) =
+ Https.post(
+ "/api/login/requirements",
+ false,
+ mapOf(
+ "username" to username,
+ ).toObject()
+ ).unsafeCast>()
+
@JsStatic
fun passwordReset(code: String, password: String) =
Https.post(
@@ -53,6 +66,27 @@ class Api {
).toObject()
)
+ @JsStatic
+ fun userRegisterTotp() =
+ Https.post(
+ "/api/user/totp/register",
+ true
+ ).unsafeCast>()
+
+ @JsStatic
+ fun userConfirmTotp() =
+ Https.post(
+ "/api/user/totp/confirm",
+ true
+ )
+
+ @JsStatic
+ fun userUnregisterTotp() =
+ Https.post(
+ "/api/user/totp/unregister",
+ true
+ )
+
@JsStatic
fun getTimeline(timeline: String, since: String? = null) =
Https.get("/api/timeline/$timeline${if (since != null) "?since=$since" else ""}", true)
diff --git a/src/main/kotlin/site/remlit/aster/route/api/LoginRoutes.kt b/src/main/kotlin/site/remlit/aster/route/api/LoginRoutes.kt
index 15569aed..4d09b8a7 100644
--- a/src/main/kotlin/site/remlit/aster/route/api/LoginRoutes.kt
+++ b/src/main/kotlin/site/remlit/aster/route/api/LoginRoutes.kt
@@ -11,7 +11,9 @@ import org.jetbrains.exposed.v1.core.and
import org.jetbrains.exposed.v1.core.eq
import site.remlit.aster.common.model.User
import site.remlit.aster.common.model.request.LoginRequest
+import site.remlit.aster.common.model.request.LoginRequirementsRequest
import site.remlit.aster.common.model.response.AuthResponse
+import site.remlit.aster.common.model.response.LoginRequirementsResponse
import site.remlit.aster.db.table.UserTable
import site.remlit.aster.model.ApiException
import site.remlit.aster.registry.RouteRegistry
@@ -58,5 +60,17 @@ internal object LoginRoutes {
call.respond(AuthResponse(token, user))
}
+
+ post("/api/login/requirements") {
+ val body = call.receive()
+
+ val private = UserService.getPrivate(UserTable.host eq null and
+ (UserTable.username eq body.username))
+ ?: throw ApiException(HttpStatusCode.NotFound, "User not found")
+
+ call.respond(LoginRequirementsResponse(
+ totp = private.totpSecret != null,
+ ))
+ }
}
}
diff --git a/src/main/kotlin/site/remlit/aster/route/api/UserRoutes.kt b/src/main/kotlin/site/remlit/aster/route/api/UserRoutes.kt
index 9f86f578..cccd793f 100644
--- a/src/main/kotlin/site/remlit/aster/route/api/UserRoutes.kt
+++ b/src/main/kotlin/site/remlit/aster/route/api/UserRoutes.kt
@@ -7,7 +7,7 @@ import io.ktor.server.routing.*
import io.ktor.server.util.*
import site.remlit.aster.common.model.User
import site.remlit.aster.common.model.generated.PartialUser
-import site.remlit.aster.common.model.request.RegisterTotpConfirmRequest
+import site.remlit.aster.common.model.request.TotpConfirmRequest
import site.remlit.aster.common.model.request.ReportRequest
import site.remlit.aster.common.model.response.RegisterTotpResponse
import site.remlit.aster.model.ApiException
@@ -196,16 +196,16 @@ internal object UserRoutes {
)
}
- post("/api/user/register-totp") {
+ post("/api/user/totp/register") {
val authenticatedUser = call.attributes[authenticatedUserKey]
call.respond(RegisterTotpResponse(
AuthService.registerTotp(authenticatedUser.id.toString())
))
}
- post("/api/user/register-totp/confirm") {
+ post("/api/user/totp/confirm") {
val authenticatedUser = call.attributes[authenticatedUserKey]
- val body = call.receive()
+ val body = call.receive()
if (!AuthService.confirmTotp(authenticatedUser.id.toString(), body.code))
throw ApiException(HttpStatusCode.Forbidden, "One time password incorrect")
@@ -213,7 +213,7 @@ internal object UserRoutes {
call.respond(HttpStatusCode.OK)
}
- post("/api/user/unregister-totp") {
+ post("/api/user/totp/unregister") {
val authenticatedUser = call.attributes[authenticatedUserKey]
AuthService.removeTotp(authenticatedUser.id.toString())
call.respond(HttpStatusCode.OK)
From cade634874bba48cfd9d645855e4903cab85e7b2 Mon Sep 17 00:00:00 2001
From: ihateblueb
Date: Sat, 10 Jan 2026 13:40:34 -0500
Subject: [PATCH 3/4] work in progress modals & totp setup
---
.../components/modal/ConfirmationModal.tsx | 32 ++++++++++++++++
.../app/src/lib/components/modal/Modal.scss | 3 ++
.../app/src/lib/components/modal/Modal.tsx | 28 ++++++++++++++
.../app/src/routes/settings/account.tsx | 38 +++++++++++++++++--
4 files changed, 97 insertions(+), 4 deletions(-)
create mode 100644 frontend/packages/app/src/lib/components/modal/ConfirmationModal.tsx
create mode 100644 frontend/packages/app/src/lib/components/modal/Modal.scss
create mode 100644 frontend/packages/app/src/lib/components/modal/Modal.tsx
diff --git a/frontend/packages/app/src/lib/components/modal/ConfirmationModal.tsx b/frontend/packages/app/src/lib/components/modal/ConfirmationModal.tsx
new file mode 100644
index 00000000..ca71d170
--- /dev/null
+++ b/frontend/packages/app/src/lib/components/modal/ConfirmationModal.tsx
@@ -0,0 +1,32 @@
+import type {Dispatch, SetStateAction} from "react";
+import Modal from "./Modal.tsx";
+import Container from "../Container.tsx";
+import Button from "../Button.tsx";
+
+function ConfirmationModal({title, body = "Are you sure you want to do this?", action, show, setShow}: {
+ title: string;
+ body: string;
+ action: () => void;
+ show: boolean;
+ setShow: Dispatch>;
+}) {
+ return (
+
+
+ {body}
+
+ {
+ setShow(false); action()
+ }}>Continue
+ setShow(false)}>Cancel
+
+
+
+ )
+}
+
+export default ConfirmationModal;
diff --git a/frontend/packages/app/src/lib/components/modal/Modal.scss b/frontend/packages/app/src/lib/components/modal/Modal.scss
new file mode 100644
index 00000000..f3d668e8
--- /dev/null
+++ b/frontend/packages/app/src/lib/components/modal/Modal.scss
@@ -0,0 +1,3 @@
+.modal {
+
+}
diff --git a/frontend/packages/app/src/lib/components/modal/Modal.tsx b/frontend/packages/app/src/lib/components/modal/Modal.tsx
new file mode 100644
index 00000000..ea8a123d
--- /dev/null
+++ b/frontend/packages/app/src/lib/components/modal/Modal.tsx
@@ -0,0 +1,28 @@
+import {type Dispatch, type ReactNode, type SetStateAction, useEffect, useState} from "react";
+import {createRef} from "preact";
+import "./Modal.scss";
+
+function Modal({title, children, show, setShow}: {
+ title: string;
+ children: ReactNode;
+ show: boolean;
+ setShow: Dispatch>;
+}) {
+ const dialog = createRef()
+
+ useEffect(() => {
+ if (show) dialog.current?.showModal()
+ if (!show) dialog.current?.close()
+ }, [show])
+
+ return (
+
+
+
+ )
+}
+
+export default Modal;
diff --git a/frontend/packages/app/src/routes/settings/account.tsx b/frontend/packages/app/src/routes/settings/account.tsx
index 84f826f6..8562a15b 100644
--- a/frontend/packages/app/src/routes/settings/account.tsx
+++ b/frontend/packages/app/src/routes/settings/account.tsx
@@ -22,6 +22,8 @@ import Input from "../../lib/components/Input.tsx";
import TextArea from "../../lib/components/TextArea.tsx";
import Button from "../../lib/components/Button.tsx";
import {Api} from 'aster-common'
+import Modal from "../../lib/components/modal/Modal.tsx";
+import {createRef} from "preact";
export const Route = createFileRoute('/settings/account')({
component: RouteComponent,
@@ -263,24 +265,52 @@ function RouteComponent() {
>
)
case 1:
+ const [show2faModal, setShow2faModal] = useState(false);
+ const [secret, setSecret] = useState("");
+
+ function start2faRegistration() {
+ setShow2faModal(true);
+ Api.userRegisterTotp().then((e) => {
+ setSecret(e.secret);
+ })
+ }
+
return (
<>
-
+
Enable 2FA
-
-
-
+
Setup passkey
+
+
+
+ In your 2FA app, enter the following secret
+
+ {secret}
+
+
+ Afterwards, you can confirm the codes generated work by clicking "Test 2FA," or you
+ can choose to disable it by pressing "Disable 2FA."
+
+ setShow2faModal(false)}>
+ All set
+
+
+
>
)
}
From ce6a52825c3a5feb939033f65957a061fa115562 Mon Sep 17 00:00:00 2001
From: ihateblueb
Date: Sat, 10 Jan 2026 18:28:53 -0500
Subject: [PATCH 4/4] Finish modals, 2fa stuff (moostly i just want to merge)
---
.../components/modal/ConfirmationModal.tsx | 12 +-
.../app/src/lib/components/modal/Modal.scss | 56 +++++++
.../app/src/lib/components/modal/Modal.tsx | 32 ++--
frontend/packages/app/src/routeTree.gen.ts | 21 +++
frontend/packages/app/src/routes/compose.tsx | 23 +++
.../app/src/routes/settings/account.tsx | 148 ++++++++++++------
.../remlit/aster/route/api/LoginRoutes.kt | 6 +-
.../site/remlit/aster/route/api/UserRoutes.kt | 6 +
8 files changed, 235 insertions(+), 69 deletions(-)
create mode 100644 frontend/packages/app/src/routes/compose.tsx
diff --git a/frontend/packages/app/src/lib/components/modal/ConfirmationModal.tsx b/frontend/packages/app/src/lib/components/modal/ConfirmationModal.tsx
index ca71d170..55a39a9a 100644
--- a/frontend/packages/app/src/lib/components/modal/ConfirmationModal.tsx
+++ b/frontend/packages/app/src/lib/components/modal/ConfirmationModal.tsx
@@ -15,15 +15,15 @@ function ConfirmationModal({title, body = "Are you sure you want to do this?", a
title={title}
show={show}
setShow={setShow}
+ actions={<>
+ {
+ setShow(false); action()
+ }}>Continue
+ setShow(false)}>Cancel
+ >}
>
{body}
-
- {
- setShow(false); action()
- }}>Continue
- setShow(false)}>Cancel
-
)
diff --git a/frontend/packages/app/src/lib/components/modal/Modal.scss b/frontend/packages/app/src/lib/components/modal/Modal.scss
index f3d668e8..0bec48c3 100644
--- a/frontend/packages/app/src/lib/components/modal/Modal.scss
+++ b/frontend/packages/app/src/lib/components/modal/Modal.scss
@@ -1,3 +1,59 @@
.modal {
+ --content-buffer: 25px;
+ &:open {
+ display: flex;
+ }
+
+ padding: 22px 16px;
+
+ flex-direction: column;
+ justify-content: center;
+ position: relative;
+
+ color: var(--tx-2);
+ background: var(--bg-2);
+ border-radius: var(--br-lg);
+ border: none;
+
+ box-shadow: var(--funky-effect),
+ 0 10px 50px #00000050;
+
+ width: 100%;
+ max-width: 375px;
+
+ &::backdrop {
+ display: none;
+ background: transparent;
+ pointer-events: none;
+ }
+
+ h1 {
+ color: var(--tx-1);
+ font-size: var(--fs-xxl);
+
+ margin: 0 0 var(--content-buffer);
+ }
+
+ p { margin: 0; }
+
+ .actions {
+ display: flex;
+ align-items: center;
+ justify-content: end;
+ gap: 10px;
+
+ margin: var(--content-buffer) 0 0;
+ bottom: 0;
+ }
+}
+
+.modalBackdrop {
+ position: absolute;
+ top: 0;
+ left: 0;
+
+ width: 100vw;
+ height: 100vh;
+ background: #00000040;
}
diff --git a/frontend/packages/app/src/lib/components/modal/Modal.tsx b/frontend/packages/app/src/lib/components/modal/Modal.tsx
index ea8a123d..e6ba8bc4 100644
--- a/frontend/packages/app/src/lib/components/modal/Modal.tsx
+++ b/frontend/packages/app/src/lib/components/modal/Modal.tsx
@@ -1,27 +1,41 @@
import {type Dispatch, type ReactNode, type SetStateAction, useEffect, useState} from "react";
import {createRef} from "preact";
import "./Modal.scss";
+import Container from "../Container.tsx";
-function Modal({title, children, show, setShow}: {
+function Modal({title, children, show = false, setShow, actions = undefined}: {
title: string;
children: ReactNode;
show: boolean;
setShow: Dispatch>;
+ actions?: ReactNode;
}) {
const dialog = createRef()
useEffect(() => {
- if (show) dialog.current?.showModal()
- if (!show) dialog.current?.close()
+ if (show && !dialog.current?.open) dialog.current?.showModal()
+ if (!show && dialog.current?.open) dialog.current?.close()
}, [show])
return (
-
-
-
+ <>
+ setShow(false)}
+ >
+ {title}
+ {children}
+ {actions ? (
+
+ {actions}
+
+ ) : null}
+
+ {show ? (
+ dialog.current?.close()}>
+ ): null}
+ >
)
}
diff --git a/frontend/packages/app/src/routeTree.gen.ts b/frontend/packages/app/src/routeTree.gen.ts
index 63f09bb4..57328be7 100644
--- a/frontend/packages/app/src/routeTree.gen.ts
+++ b/frontend/packages/app/src/routeTree.gen.ts
@@ -18,6 +18,7 @@ import { Route as LoginRouteImport } from './routes/login'
import { Route as ForgotPasswordRouteImport } from './routes/forgot-password'
import { Route as FollowRequestsRouteImport } from './routes/follow-requests'
import { Route as DriveRouteImport } from './routes/drive'
+import { Route as ComposeRouteImport } from './routes/compose'
import { Route as BookmarksRouteImport } from './routes/bookmarks'
import { Route as AboutRouteImport } from './routes/about'
import { Route as SplatRouteImport } from './routes/$'
@@ -71,6 +72,11 @@ const DriveRoute = DriveRouteImport.update({
path: '/drive',
getParentRoute: () => rootRouteImport,
} as any)
+const ComposeRoute = ComposeRouteImport.update({
+ id: '/compose',
+ path: '/compose',
+ getParentRoute: () => rootRouteImport,
+} as any)
const BookmarksRoute = BookmarksRouteImport.update({
id: '/bookmarks',
path: '/bookmarks',
@@ -112,6 +118,7 @@ export interface FileRoutesByFullPath {
'/$': typeof SplatRoute
'/about': typeof AboutRoute
'/bookmarks': typeof BookmarksRoute
+ '/compose': typeof ComposeRoute
'/drive': typeof DriveRoute
'/follow-requests': typeof FollowRequestsRoute
'/forgot-password': typeof ForgotPasswordRoute
@@ -130,6 +137,7 @@ export interface FileRoutesByTo {
'/$': typeof SplatRoute
'/about': typeof AboutRoute
'/bookmarks': typeof BookmarksRoute
+ '/compose': typeof ComposeRoute
'/drive': typeof DriveRoute
'/follow-requests': typeof FollowRequestsRoute
'/forgot-password': typeof ForgotPasswordRoute
@@ -149,6 +157,7 @@ export interface FileRoutesById {
'/$': typeof SplatRoute
'/about': typeof AboutRoute
'/bookmarks': typeof BookmarksRoute
+ '/compose': typeof ComposeRoute
'/drive': typeof DriveRoute
'/follow-requests': typeof FollowRequestsRoute
'/forgot-password': typeof ForgotPasswordRoute
@@ -169,6 +178,7 @@ export interface FileRouteTypes {
| '/$'
| '/about'
| '/bookmarks'
+ | '/compose'
| '/drive'
| '/follow-requests'
| '/forgot-password'
@@ -187,6 +197,7 @@ export interface FileRouteTypes {
| '/$'
| '/about'
| '/bookmarks'
+ | '/compose'
| '/drive'
| '/follow-requests'
| '/forgot-password'
@@ -205,6 +216,7 @@ export interface FileRouteTypes {
| '/$'
| '/about'
| '/bookmarks'
+ | '/compose'
| '/drive'
| '/follow-requests'
| '/forgot-password'
@@ -224,6 +236,7 @@ export interface RootRouteChildren {
SplatRoute: typeof SplatRoute
AboutRoute: typeof AboutRoute
BookmarksRoute: typeof BookmarksRoute
+ ComposeRoute: typeof ComposeRoute
DriveRoute: typeof DriveRoute
FollowRequestsRoute: typeof FollowRequestsRoute
ForgotPasswordRoute: typeof ForgotPasswordRoute
@@ -303,6 +316,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof DriveRouteImport
parentRoute: typeof rootRouteImport
}
+ '/compose': {
+ id: '/compose'
+ path: '/compose'
+ fullPath: '/compose'
+ preLoaderRoute: typeof ComposeRouteImport
+ parentRoute: typeof rootRouteImport
+ }
'/bookmarks': {
id: '/bookmarks'
path: '/bookmarks'
@@ -360,6 +380,7 @@ const rootRouteChildren: RootRouteChildren = {
SplatRoute: SplatRoute,
AboutRoute: AboutRoute,
BookmarksRoute: BookmarksRoute,
+ ComposeRoute: ComposeRoute,
DriveRoute: DriveRoute,
FollowRequestsRoute: FollowRequestsRoute,
ForgotPasswordRoute: ForgotPasswordRoute,
diff --git a/frontend/packages/app/src/routes/compose.tsx b/frontend/packages/app/src/routes/compose.tsx
new file mode 100644
index 00000000..4a12228d
--- /dev/null
+++ b/frontend/packages/app/src/routes/compose.tsx
@@ -0,0 +1,23 @@
+import { createFileRoute } from '@tanstack/react-router'
+import PageHeader from "../lib/components/PageHeader.tsx";
+import {IconPencil} from "@tabler/icons-react";
+import PageWrapper from "../lib/components/PageWrapper.tsx";
+import Compose from "../lib/components/Compose.tsx";
+
+export const Route = createFileRoute('/compose')({
+ component: RouteComponent,
+})
+
+function RouteComponent() {
+ return (
+ <>
+ }
+ title={"Compose"}
+ />
+
+
+
+ >
+ )
+}
diff --git a/frontend/packages/app/src/routes/settings/account.tsx b/frontend/packages/app/src/routes/settings/account.tsx
index 8562a15b..14d37e74 100644
--- a/frontend/packages/app/src/routes/settings/account.tsx
+++ b/frontend/packages/app/src/routes/settings/account.tsx
@@ -1,10 +1,10 @@
import {createFileRoute, useNavigate} from '@tanstack/react-router'
import PageHeader from "../../lib/components/PageHeader.tsx";
import {
- IconAuth2fa,
- IconDeviceDesktop, IconKey,
- IconLockPassword,
- IconLogout, IconPassword,
+ IconDeviceDesktop,
+ IconKey, IconLock, IconLockOff,
+ IconLogout,
+ IconPassword,
IconSettings,
IconShield,
IconUser
@@ -23,7 +23,8 @@ import TextArea from "../../lib/components/TextArea.tsx";
import Button from "../../lib/components/Button.tsx";
import {Api} from 'aster-common'
import Modal from "../../lib/components/modal/Modal.tsx";
-import {createRef} from "preact";
+import ConfirmationModal from "../../lib/components/modal/ConfirmationModal.tsx";
+import alert, {Alert, AlertType} from "../../lib/utils/alert.ts";
export const Route = createFileRoute('/settings/account')({
component: RouteComponent,
@@ -34,48 +35,54 @@ function RouteComponent() {
const [tab, setTab] = useState(0)
+ function navigateTab(tab: number) {
+ setTab(tab)
+ }
+
let self = localstore.getSelf()
if (!self) navigate({to: "/"})
- const {data, error, isPending, isFetching, refetch} = useQuery({
- queryKey: [`user_${localstore.getSelf()?.id}`],
- queryFn: () => Api.getUser(self.id),
- });
+ const {data, error, isPending, isFetching, refetch} = useQuery({
+ queryKey: [`user_${localstore.getSelf()?.id}`],
+ queryFn: () => Api.getUser(self.id),
+ });
- const form = useForm({
- defaultValues: {
- displayName: data?.displayName,
- bio: data?.bio,
- location: data?.location,
- birthday: data?.birthday,
+ const form = useForm({
+ defaultValues: {
+ displayName: data?.displayName,
+ bio: data?.bio,
+ location: data?.location,
+ birthday: data?.birthday,
- avatar: data?.avatar,
- avatarAlt: data?.avatarAlt,
- banner: data?.banner,
- bannerAlt: data?.bannerAlt,
+ avatar: data?.avatar,
+ avatarAlt: data?.avatarAlt,
+ banner: data?.banner,
+ bannerAlt: data?.bannerAlt,
- locked: data?.locked,
- suspended: data?.suspended,
- activated: data?.activated,
- automated: data?.automated,
- discoverable: data?.discoverable,
- indexable: data?.indexable,
- sensitive: data?.sensitive,
+ locked: data?.locked,
+ suspended: data?.suspended,
+ activated: data?.activated,
+ automated: data?.automated,
+ discoverable: data?.discoverable,
+ indexable: data?.indexable,
+ sensitive: data?.sensitive,
- isCat: data?.isCat,
- speakAsCat: data?.speakAsCat
- },
- onSubmit: async (values) => {
- console.log(values)
- Api.editUser(self.id, values.value).then((result) => {
- if (result) {
- self = result
- localstore.set("self", JSON.stringify(self))
- }
- })
- }
- })
+ isCat: data?.isCat,
+ speakAsCat: data?.speakAsCat
+ },
+ onSubmit: async (values) => {
+ console.log(values)
+ Api.editUser(self.id, values.value).then((result) => {
+ if (result) {
+ self = result
+ localstore.set("self", JSON.stringify(self))
+ }
+ })
+ }
+ })
+
+ const [showLogoutModel, setShowLogoutModel] = useState(false);
function renderTab() {
switch (tab) {
@@ -265,40 +272,72 @@ function RouteComponent() {
>
)
case 1:
+ const loginRequirement = useQuery({
+ queryKey: [`user_loginreq_${localstore.getSelf()?.id}`],
+ queryFn: () => Api.getLoginRequirements(self.username),
+ });
+
const [show2faModal, setShow2faModal] = useState(false);
const [secret, setSecret] = useState("");
function start2faRegistration() {
setShow2faModal(true);
Api.userRegisterTotp().then((e) => {
- setSecret(e.secret);
+ setSecret(e?.secret);
})
}
+ function unregister2fa() {
+ Api.userUnregisterTotp()
+ alert.add(new Alert("", AlertType.Success, "Unregistered 2FA"))
+ }
+
return (
<>
-
-
- Enable 2FA
-
+ {loginRequirement.data?.totp ? (
+ <>
+ {/*
+
+
+ Test 2FA
+
+ */}
+
+
+ Disable 2FA
+
+ >
+ ) : (
+
+
+ Enable 2FA
+
+ )}
+ {/*
Setup passkey
+ */}
+ setShow2faModal(false)}>
+ Continue
+
+ >}
>
-
- In your 2FA app, enter the following secret
+
+ In your 2FA app, enter the following secret:
{secret}
@@ -306,9 +345,6 @@ function RouteComponent() {
Afterwards, you can confirm the codes generated work by clicking "Test 2FA," or you
can choose to disable it by pressing "Disable 2FA."
- setShow2faModal(false)}>
- All set
-
>
@@ -337,21 +373,21 @@ function RouteComponent() {
setTab(0)}
+ onClick={() => navigateTab(0)}
>
General
setTab(1)}
+ onClick={() => navigateTab(1)}
>
Security
-
+ setShowLogoutModel(true)}>
Logout
@@ -360,6 +396,14 @@ function RouteComponent() {
{renderTab()}
+
+ navigate({to:"/logout"})}
+ show={showLogoutModel}
+ setShow={setShowLogoutModel}
+ />
>
)
diff --git a/src/main/kotlin/site/remlit/aster/route/api/LoginRoutes.kt b/src/main/kotlin/site/remlit/aster/route/api/LoginRoutes.kt
index 4d09b8a7..1bf7cc30 100644
--- a/src/main/kotlin/site/remlit/aster/route/api/LoginRoutes.kt
+++ b/src/main/kotlin/site/remlit/aster/route/api/LoginRoutes.kt
@@ -64,8 +64,10 @@ internal object LoginRoutes {
post("/api/login/requirements") {
val body = call.receive()
- val private = UserService.getPrivate(UserTable.host eq null and
- (UserTable.username eq body.username))
+ val user = UserService.getByUsername(body.username)
+ ?: throw ApiException(HttpStatusCode.NotFound, "User not found")
+
+ val private = UserService.getPrivateById(user.id.toString())
?: throw ApiException(HttpStatusCode.NotFound, "User not found")
call.respond(LoginRequirementsResponse(
diff --git a/src/main/kotlin/site/remlit/aster/route/api/UserRoutes.kt b/src/main/kotlin/site/remlit/aster/route/api/UserRoutes.kt
index cccd793f..4727df8e 100644
--- a/src/main/kotlin/site/remlit/aster/route/api/UserRoutes.kt
+++ b/src/main/kotlin/site/remlit/aster/route/api/UserRoutes.kt
@@ -198,6 +198,12 @@ internal object UserRoutes {
post("/api/user/totp/register") {
val authenticatedUser = call.attributes[authenticatedUserKey]
+
+ val private = UserService.getPrivateById(authenticatedUser.id.toString())
+
+ if (private?.totpSecret != null)
+ throw ApiException(HttpStatusCode.Conflict, "Already setup")
+
call.respond(RegisterTotpResponse(
AuthService.registerTotp(authenticatedUser.id.toString())
))