From 0d0e38e4b64d6c6d3a8a75311d890310eb4ec523 Mon Sep 17 00:00:00 2001 From: Hornwitser Date: Wed, 9 Jul 2025 17:57:49 +0200 Subject: [PATCH] Refactor demo login as an authentication method Use the authentication method system for the demo login and the generated accounts. This makes it possible to toggle it off on production systems as these shouldn't have it enabled at all. --- components/LogIn.vue | 29 ++++++ components/LogInDemo.vue | 41 +++++++++ docs/admin/config.md | 6 ++ nuxt.config.ts | 1 + pages/login.vue | 88 +------------------ pages/register.vue | 71 ++++++++++++--- server/api/admin/database-import-demo.post.ts | 13 ++- server/api/auth/account.post.ts | 15 +++- server/api/auth/ap/demo-login.post.ts | 38 ++++++++ server/api/auth/login.post.ts | 22 ----- server/database.ts | 10 ++- server/utils/session.ts | 4 +- shared/types/api.ts | 7 +- stores/session.ts | 8 -- 14 files changed, 212 insertions(+), 141 deletions(-) create mode 100644 components/LogIn.vue create mode 100644 components/LogInDemo.vue create mode 100644 server/api/auth/ap/demo-login.post.ts delete mode 100644 server/api/auth/login.post.ts diff --git a/components/LogIn.vue b/components/LogIn.vue new file mode 100644 index 0000000..e2d56ca --- /dev/null +++ b/components/LogIn.vue @@ -0,0 +1,29 @@ + + + + + + diff --git a/components/LogInDemo.vue b/components/LogInDemo.vue new file mode 100644 index 0000000..31cdbf8 --- /dev/null +++ b/components/LogInDemo.vue @@ -0,0 +1,41 @@ + + + + + + diff --git a/docs/admin/config.md b/docs/admin/config.md index b95e08d..cf22886 100644 --- a/docs/admin/config.md +++ b/docs/admin/config.md @@ -20,6 +20,12 @@ Time in seconds before a session is deleted from the client and server, resultin This should be several times greater that `NUXT_SESSION_ROTATES_TIMEOUT`. +### NUXT_AUTH_DEMO_ENABLED + +Boolean indicating if the demo authentication provider should be enabled. This allows logging in using only a name with no additional checks or security and should _never_ be enabled on a production system. The purpose of this is to make it easier to demo the system. + +Defaults to `false`. + ### NUXT_TELEGRAM_BOT_TOKEN_FILE Path to a file containing the token for the Telegram bot used for authenticating users via Telegram. diff --git a/nuxt.config.ts b/nuxt.config.ts index b970358..42624ba 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -31,6 +31,7 @@ export default defineNuxtConfig({ public: { defaultTimezone: "Europe/Oslo", defaultLocale: "en-GB", + authDemoEnabled: false, authTelegramEnabled: false, telegramBotUsername: "", vapidPublicKey: "", diff --git a/pages/login.vue b/pages/login.vue index 01b233f..c7b9cdd 100644 --- a/pages/login.vue +++ b/pages/login.vue @@ -5,32 +5,10 @@ @@ -38,66 +16,4 @@ useHead({ title: "Login", }); - -const runtimeConfig = useRuntimeConfig(); -const authTelegramEnabled = runtimeConfig.public.authTelegramEnabled; -const sessionStore = useSessionStore(); -const { getSubscription, subscribe } = usePushNotification(); - -const name = ref(""); -const result = ref("") -async function logIn() { - try { - result.value = await sessionStore.logIn(name.value); - // Resubscribe push notifications if the user was subscribed before. - const subscription = await getSubscription(); - if (subscription) { - await subscribe(); - } - // XXX Remove the need for this. - await sessionStore.fetch(); - - } catch (err: any) { - console.log(err); - console.log(err.data); - result.value = `Server replied: ${err.statusCode} ${err.statusMessage}`; - } -} - -const createName = ref(""); -async function createAccount() { - try { - const res = await $fetch.raw("/api/auth/account", { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - }, - body: new URLSearchParams({ name: createName.value }) - }); - result.value = `Server replied: ${res.status} ${res.statusText}`; - await sessionStore.fetch(); - - } catch (err: any) { - console.log(err); - console.log(err.data); - result.value = `Server replied: ${err.statusCode} ${err.statusMessage}`; - } -} -async function createAnonymousAccount() { - try { - const res = await $fetch.raw("/api/auth/account", { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - }, - }); - result.value = `Server replied: ${res.status} ${res.statusText}`; - await sessionStore.fetch(); - - } catch (err: any) { - console.log(err); - console.log(err.data); - result.value = `Server replied: ${err.statusCode} ${err.statusMessage}`; - } -} diff --git a/pages/register.vue b/pages/register.vue index f9de244..7ed2ad6 100644 --- a/pages/register.vue +++ b/pages/register.vue @@ -5,43 +5,74 @@ + + diff --git a/server/api/admin/database-import-demo.post.ts b/server/api/admin/database-import-demo.post.ts index fe85be8..cab0eae 100644 --- a/server/api/admin/database-import-demo.post.ts +++ b/server/api/admin/database-import-demo.post.ts @@ -2,10 +2,19 @@ SPDX-FileCopyrightText: © 2025 Hornwitser SPDX-License-Identifier: AGPL-3.0-or-later */ -import { writeSchedule, writeUsers } from "~/server/database"; +import { nextAuthenticationMethodId, writeAuthenticationMethods, writeNextAuthenticationMethodId, writeSchedule, writeUsers } from "~/server/database"; import { generateDemoSchedule, generateDemoAccounts } from "~/server/generate-demo-schedule"; export default defineEventHandler(async (event) => { await requireServerSessionWithAdmin(event); - await writeUsers(generateDemoAccounts()); + const accounts = generateDemoAccounts(); + await writeUsers(accounts); await writeSchedule(generateDemoSchedule()); + await writeAuthenticationMethods(accounts.map((user, index) => ({ + id: index, + userId: user.id, + provider: "demo", + slug: user.name!, + name: user.name!, + }))); + await writeNextAuthenticationMethodId(Math.max(await nextAuthenticationMethodId(), accounts.length)); }) diff --git a/server/api/auth/account.post.ts b/server/api/auth/account.post.ts index 2fcbc71..1b2d033 100644 --- a/server/api/auth/account.post.ts +++ b/server/api/auth/account.post.ts @@ -15,8 +15,8 @@ export default defineEventHandler(async (event): Promise => { }); } - const formData = await readBody(event); - const name = formData.name; + const body = await readBody(event); + const name = body?.name; const users = await readUsers(); let user: ServerUser; @@ -42,7 +42,7 @@ export default defineEventHandler(async (event): Promise => { name, }; - } else if (name === null) { + } else if (name === undefined) { user = { id: await nextUserId(), updatedAt: new Date().toISOString(), @@ -55,7 +55,14 @@ export default defineEventHandler(async (event): Promise => { }); } - if (session?.authenticationProvider) { + if (user.type !== "anonymous") { + if (!session?.authenticationProvider) { + throw createError({ + statusCode: 409, + statusMessage: "Conflict", + message: "User account need an authentication method associated with it.", + }); + } const authMethods = await readAuthenticationMethods(); const method = authMethods.find(method => ( method.provider === session.authenticationProvider diff --git a/server/api/auth/ap/demo-login.post.ts b/server/api/auth/ap/demo-login.post.ts new file mode 100644 index 0000000..ffd07de --- /dev/null +++ b/server/api/auth/ap/demo-login.post.ts @@ -0,0 +1,38 @@ +/* + SPDX-FileCopyrightText: © 2025 Hornwitser + SPDX-License-Identifier: AGPL-3.0-or-later +*/ +import { readAuthenticationMethods, readUsers } from "~/server/database"; + +export default defineEventHandler(async (event) => { + const runtimeConfig = useRuntimeConfig(event); + if (!runtimeConfig.public.authDemoEnabled) { + throw createError({ + statusCode: 403, + statusMessage: "Forbidden", + message: "Demo authentication is disabled", + }); + } + + const { name: slug } = await readBody(event); + + if (typeof slug !== "string" || !slug) { + throw createError({ + statusCode: 400, + statusMessage: "Bad Request", + message: "Missing name", + }); + } + + const authMethods = await readAuthenticationMethods(); + const method = authMethods.find(method => method.provider === "demo" && method.slug === slug); + let session; + if (method) { + const users = await readUsers(); + const account = users.find(user => !user.deleted && user.id === method.userId); + session = await setServerSession(event, account); + } else { + session = await setServerSession(event, undefined, "demo", slug, slug); + } + return await serverSessionToApi(event, session); +}) diff --git a/server/api/auth/login.post.ts b/server/api/auth/login.post.ts deleted file mode 100644 index c390d1c..0000000 --- a/server/api/auth/login.post.ts +++ /dev/null @@ -1,22 +0,0 @@ -/* - SPDX-FileCopyrightText: © 2025 Hornwitser - SPDX-License-Identifier: AGPL-3.0-or-later -*/ -import { readUsers } from "~/server/database"; - -export default defineEventHandler(async (event) => { - const { name } = await readBody(event); - - if (!name) { - return new Response(undefined, { status: 400 }) - } - - const accounts = await readUsers(); - const account = accounts.find(a => a.name === name); - - if (!account) { - return new Response(undefined, { status: 403 }) - } - - await setServerSession(event, account); -}) diff --git a/server/database.ts b/server/database.ts index da703e5..db5226a 100644 --- a/server/database.ts +++ b/server/database.ts @@ -3,14 +3,14 @@ SPDX-License-Identifier: AGPL-3.0-or-later */ import { readFile, unlink, writeFile } from "node:fs/promises"; -import type { ApiSchedule, ApiSubscription, ApiUserType } from "~/shared/types/api"; +import type { ApiAuthenticationProvider, ApiSchedule, ApiSubscription, ApiUserType } from "~/shared/types/api"; import type { Id } from "~/shared/types/common"; export interface ServerSession { id: Id, access: ApiUserType, accountId?: number, - authenticationProvider?: "telegram", + authenticationProvider?: ApiAuthenticationProvider, authenticationSlug?: string, authenticationName?: string, rotatesAtMs: number, @@ -33,7 +33,7 @@ export interface ServerUser { export interface ServerAuthenticationMethod { id: Id, - provider: "telegram", + provider: ApiAuthenticationProvider, slug: string, name: string, userId: Id, @@ -157,6 +157,10 @@ export async function nextAuthenticationMethodId() { return nextId; } +export async function writeNextAuthenticationMethodId(nextId: number) { + await writeFile(nextAuthenticationMethodIdPath, String(nextId), "utf-8"); +} + export async function readAuthenticationMethods() { return readJson(authMethodPath, []) } diff --git a/server/utils/session.ts b/server/utils/session.ts index 9c824e7..f6d9d77 100644 --- a/server/utils/session.ts +++ b/server/utils/session.ts @@ -14,7 +14,7 @@ import { writeSubscriptions } from "~/server/database"; import { broadcastEvent } from "../streams"; -import type { ApiSession } from "~/shared/types/api"; +import type { ApiAuthenticationProvider, ApiSession } from "~/shared/types/api"; async function removeSessionSubscription(sessionId: number) { const subscriptions = await readSubscriptions(); @@ -54,7 +54,7 @@ export async function clearServerSession(event: H3Event) { export async function setServerSession( event: H3Event, account: ServerUser | undefined, - authenticationProvider?: "telegram", + authenticationProvider?: ApiAuthenticationProvider, authenticationSlug?: string, authenticationName?: string, ) { diff --git a/shared/types/api.ts b/shared/types/api.ts index 64812ac..7b82c80 100644 --- a/shared/types/api.ts +++ b/shared/types/api.ts @@ -67,10 +67,15 @@ export const apiSubscriptionSchema = z.object({ }); export type ApiSubscription = z.infer; +export type ApiAuthenticationProvider = + | "demo" + | "telegram" +; + export interface ApiSession { id: Id, account?: ApiAccount, - authenticationProvider?: "telegram", + authenticationProvider?: ApiAuthenticationProvider, authenticationName?: string, push: boolean, } diff --git a/stores/session.ts b/stores/session.ts index ddba323..380390b 100644 --- a/stores/session.ts +++ b/stores/session.ts @@ -44,14 +44,6 @@ export const useSessionStore = defineStore("session", () => { state.id.value = session?.id; state.push.value = session?.push ?? false; }, - async logIn(name: string) { - const res = await $fetch.raw("/api/auth/login", { - method: "POST", - body: { name }, - }); - await actions.fetch(); - return `/api/auth/login replied: ${res.status} ${res.statusText}`; - }, async logOut() { try { await $fetch.raw("/api/auth/session", {