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}

+ + + + +
+
+ ) +} + +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 ( <> - + + + +

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." +

+ +
+
) } 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={<> + + + } >

{body}

- - - -
) 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 ( <> - + {loginRequirement.data?.totp ? ( + <> + {/* + + */} + + + ) : ( + + )} + {/* + */} + + } > - -

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."

-
@@ -337,21 +373,21 @@ function RouteComponent() { setTab(0)} + onClick={() => navigateTab(0)} > General setTab(1)} + onClick={() => navigateTab(1)} > Security - @@ -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()) ))