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", {