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/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/TotpConfirmRequest.kt b/common/src/commonMain/kotlin/site/remlit/aster/common/model/request/TotpConfirmRequest.kt new file mode 100644 index 00000000..b5df6f9a --- /dev/null +++ b/common/src/commonMain/kotlin/site/remlit/aster/common/model/request/TotpConfirmRequest.kt @@ -0,0 +1,10 @@ +package site.remlit.aster.common.model.request + +import kotlinx.serialization.Serializable +import kotlin.js.JsExport + +@JsExport +@Serializable +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/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/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/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..55a39a9a --- /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..0bec48c3 --- /dev/null +++ b/frontend/packages/app/src/lib/components/modal/Modal.scss @@ -0,0 +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 new file mode 100644 index 00000000..e6ba8bc4 --- /dev/null +++ b/frontend/packages/app/src/lib/components/modal/Modal.tsx @@ -0,0 +1,42 @@ +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 = false, setShow, actions = undefined}: { + title: string; + children: ReactNode; + show: boolean; + setShow: Dispatch>; + actions?: ReactNode; +}) { + const dialog = createRef() + + useEffect(() => { + 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} + + ) +} + +export default Modal; 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 84f826f6..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 @@ -22,6 +22,9 @@ 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 ConfirmationModal from "../../lib/components/modal/ConfirmationModal.tsx"; +import alert, {Alert, AlertType} from "../../lib/utils/alert.ts"; export const Route = createFileRoute('/settings/account')({ component: RouteComponent, @@ -32,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) { @@ -263,24 +272,81 @@ 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); + }) + } + + function unregister2fa() { + Api.userUnregisterTotp() + alert.add(new Alert("", AlertType.Success, "Unregistered 2FA")) + } + return ( <> - + {loginRequirement.data?.totp ? ( + <> + {/* + + */} + + + ) : ( + + )} + {/* + */} + + + + } + > + +

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

+
+
) } @@ -307,21 +373,21 @@ function RouteComponent() { setTab(0)} + onClick={() => navigateTab(0)} > General setTab(1)} + onClick={() => navigateTab(1)} > Security - @@ -330,6 +396,14 @@ function RouteComponent() { {renderTab()} + + navigate({to:"/logout"})} + show={showLogoutModel} + setShow={setShowLogoutModel} + /> ) 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..1bf7cc30 100644 --- a/src/main/kotlin/site/remlit/aster/route/api/LoginRoutes.kt +++ b/src/main/kotlin/site/remlit/aster/route/api/LoginRoutes.kt @@ -5,11 +5,15 @@ 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.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 @@ -18,16 +22,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 +42,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,9 +50,29 @@ 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)) } + + post("/api/login/requirements") { + val body = call.receive() + + 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( + 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 03961525..4727df8e 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.TotpConfirmRequest 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,35 @@ 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()) + )) + } + + post("/api/user/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/totp/unregister") { + 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() + } }