diff --git a/components/Header.vue b/components/Header.vue index 0d9e95d..fd79684 100644 --- a/components/Header.vue +++ b/components/Header.vue @@ -33,6 +33,7 @@ diff --git a/components/LogInTelegram.vue b/components/LogInTelegram.vue new file mode 100644 index 0000000..d56fa98 --- /dev/null +++ b/components/LogInTelegram.vue @@ -0,0 +1,61 @@ + + + + diff --git a/docs/admin/authentication.md b/docs/admin/authentication.md new file mode 100644 index 0000000..a1fbc3e --- /dev/null +++ b/docs/admin/authentication.md @@ -0,0 +1,15 @@ + +# Authentication + +It's possible to configure authentication using a third party Authentication Provider (referred to as AP). Currently only Telegram is supported as an AP. + +## Telegram + +In order to use Telegram as an AP you need to be hosting Owltide under a domain name over https, using http will not work. + +You will also need a bot which can be created by messaging [@BotFather](https://t.me/BotFather), with the domain of the bot set using the `/setdomain` command to the domain Owltide is hosted under. + +Once you have the pre-requisites you need to configure `NUXT_TELEGRAM_BOT_TOKEN_FILE` to a path to a file containing the token of the bot with no spaces or new-lines. `NUXT_PUBLIC_TELEGRAM_BOT_USERNAME` to the username of the bot. And finally `NUXT_AUTH_TELEGRAM_ENABLED` to `true` to enable authentication via Telegram. diff --git a/docs/admin/config.md b/docs/admin/config.md index e273f7d..b95e08d 100644 --- a/docs/admin/config.md +++ b/docs/admin/config.md @@ -19,3 +19,21 @@ Time in seconds before a session need to be rotated over into a new session. Whe Time in seconds before a session is deleted from the client and server, resulting in the user having to authenticate again if the session wasn't rotated over into a new session before this timeout. This should be several times greater that `NUXT_SESSION_ROTATES_TIMEOUT`. + +### NUXT_TELEGRAM_BOT_TOKEN_FILE + +Path to a file containing the token for the Telegram bot used for authenticating users via Telegram. + +Does nothing if `NUXT_AUTH_TELEGRAM_ENABLED` is not enabled. + +### NUXT_PUBLIC_TELEGRAM_BOT_USERNAME + +Username of the Telegram bot used for authenticating users via Telegram. + +Does nothing if `NUXT_AUTH_TELEGRAM_ENABLED` is not enabled. + +### NUXT_AUTH_TELEGRAM_ENABLED + +Boolean indicating if authentication via Telegram is enabled or not. Requires `NUXT_PUBLIC_TELEGRAM_BOT_USERNAME` and `NUXT_TELEGRAM_BOT_TOKEN_FILE` to be set in order to work. + +Defaults to `false`. diff --git a/nuxt.config.ts b/nuxt.config.ts index 6ff336a..b970358 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -27,9 +27,12 @@ export default defineNuxtConfig({ sessionDiscardTimeout: 14 * oneDaySeconds, vapidSubject: "", vapidPrivateKeyFile: "", + telegramBotTokenFile: "", public: { defaultTimezone: "Europe/Oslo", defaultLocale: "en-GB", + authTelegramEnabled: false, + telegramBotUsername: "", vapidPublicKey: "", } }, diff --git a/pages/login.vue b/pages/login.vue index cdb04b8..01b233f 100644 --- a/pages/login.vue +++ b/pages/login.vue @@ -9,6 +9,7 @@ +

Create Account

If you don't have an account you may create one

@@ -38,6 +39,8 @@ useHead({ title: "Login", }); +const runtimeConfig = useRuntimeConfig(); +const authTelegramEnabled = runtimeConfig.public.authTelegramEnabled; const sessionStore = useSessionStore(); const { getSubscription, subscribe } = usePushNotification(); diff --git a/pages/register.vue b/pages/register.vue new file mode 100644 index 0000000..f9de244 --- /dev/null +++ b/pages/register.vue @@ -0,0 +1,57 @@ + + + + diff --git a/server/api/auth/account.post.ts b/server/api/auth/account.post.ts index 527215c..2fcbc71 100644 --- a/server/api/auth/account.post.ts +++ b/server/api/auth/account.post.ts @@ -2,20 +2,21 @@ SPDX-FileCopyrightText: © 2025 Hornwitser SPDX-License-Identifier: AGPL-3.0-or-later */ -import { readUsers, writeUsers, nextUserId, type ServerUser } from "~/server/database"; +import { readUsers, writeUsers, nextUserId, type ServerUser, readAuthenticationMethods, nextAuthenticationMethodId, writeAuthenticationMethods } from "~/server/database"; import { broadcastEvent } from "~/server/streams"; +import { ApiSession } from "~/shared/types/api"; -export default defineEventHandler(async (event) => { +export default defineEventHandler(async (event): Promise => { let session = await getServerSession(event, false); - if (session) { + if (session?.accountId !== undefined) { throw createError({ status: 409, - message: "Cannot create account while having an active session." + message: "Cannot create account while logged in to an account." }); } - const formData = await readFormData(event); - const name = formData.get("name"); + const formData = await readBody(event); + const name = formData.name; const users = await readUsers(); let user: ServerUser; @@ -54,11 +55,35 @@ export default defineEventHandler(async (event) => { }); } + if (session?.authenticationProvider) { + const authMethods = await readAuthenticationMethods(); + const method = authMethods.find(method => ( + method.provider === session.authenticationProvider + && method.slug === session.authenticationSlug + )); + if (method) { + throw createError({ + statusCode: 409, + statusMessage: "Conflict", + message: "A user is already associated with the authentication method", + }); + } + authMethods.push({ + id: await nextAuthenticationMethodId(), + userId: user.id, + provider: session.authenticationProvider, + slug: session.authenticationSlug!, + name: session.authenticationName!, + }) + await writeAuthenticationMethods(authMethods); + } + users.push(user); await writeUsers(users); await broadcastEvent({ type: "user-update", data: user, }); - await setServerSession(event, user); + const newSession = await setServerSession(event, user); + return await serverSessionToApi(event, newSession); }) diff --git a/server/api/auth/ap/telegram-login.post.ts b/server/api/auth/ap/telegram-login.post.ts new file mode 100644 index 0000000..d3c69fd --- /dev/null +++ b/server/api/auth/ap/telegram-login.post.ts @@ -0,0 +1,100 @@ +/* + SPDX-FileCopyrightText: © 2025 Hornwitser + SPDX-License-Identifier: AGPL-3.0-or-later +*/ +import * as fs from "node:fs/promises"; +import type { H3Event } from "h3"; +import { string, z } from "zod/v4-mini"; +import { readAuthenticationMethods, readUsers } from "~/server/database"; +import { type TelegramAuthData, telegramAuthDataSchema } from "~/shared/types/telegram"; +import { ApiSession } from "~/shared/types/api"; + +const loginSchema = z.object({ + authData: telegramAuthDataSchema, +}); + +let cachedTelegramConfig: + | undefined + | { enabled: false } + | { enabled: true, botUsername: string, secretKey: CryptoKey } +; +async function useTelegramConfig(event: H3Event) { + if (cachedTelegramConfig) + return cachedTelegramConfig; + + const runtimeConfig = useRuntimeConfig(event); + if (!runtimeConfig.public.authTelegramEnabled) { + return cachedTelegramConfig = { + enabled: false, + }; + } + if (!runtimeConfig.telegramBotTokenFile) throw new Error("NUXT_TELEGRAM_BOT_TOKEN_FILE not configured"); + if (!runtimeConfig.public.telegramBotUsername) throw new Error("NUXT_PUBLIC_TELEGRAM_BOT_USERNAME not configured"); + + const botToken = await fs.readFile(runtimeConfig.telegramBotTokenFile); + const secretKey = await crypto.subtle.importKey( + "raw", + await crypto.subtle.digest("SHA-256", botToken), + { + name: "HMAC", + hash: "SHA-256", + }, + false, + ["verify"], + ); + return cachedTelegramConfig = { + enabled: true, + botUsername: runtimeConfig.public.telegramBotUsername, + secretKey, + } +} + +async function validateTelegramAuthData(authData: TelegramAuthData, key: CryptoKey) { + const { hash, ...checkData } = authData; + const checkString = Object.entries(checkData).map(([key, value]) => `${key}=${value}`).sort().join("\n"); + const signature = Buffer.from(hash, "hex"); + return await crypto.subtle.verify("HMAC", key, signature, Buffer.from(checkString)); +} + +export default defineEventHandler(async (event): Promise => { + const { success, error, data } = loginSchema.safeParse(await readBody(event)); + if (!success) { + throw createError({ + statusCode: 400, + statusMessage: "Bad Request", + message: z.prettifyError(error), + }); + } + + const config = await useTelegramConfig(event); + if (!config.enabled) { + throw createError({ + statusCode: 403, + statusMessage: "Forbidden", + message: "Telegram authentication is disabled", + }); + } + + if (!await validateTelegramAuthData(data.authData, config.secretKey)) { + throw createError({ + statusCode: 403, + statusMessage: "Forbidden", + message: "Validating authentication data failed", + }); + } + + const slug = String(data.authData.id); + const authMethods = await readAuthenticationMethods(); + const method = authMethods.find(method => method.provider === "telegram" && 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 { + const name = data.authData.username ? "@" + data.authData.username : slug; + session = await setServerSession(event, undefined, "telegram", slug, name); + } + + return await serverSessionToApi(event, session); +}) diff --git a/server/database.ts b/server/database.ts index e3b5c0b..da703e5 100644 --- a/server/database.ts +++ b/server/database.ts @@ -10,6 +10,9 @@ export interface ServerSession { id: Id, access: ApiUserType, accountId?: number, + authenticationProvider?: "telegram", + authenticationSlug?: string, + authenticationName?: string, rotatesAtMs: number, expiresAtMs?: number, discardAtMs: number, @@ -28,6 +31,14 @@ export interface ServerUser { locale?: string, } +export interface ServerAuthenticationMethod { + id: Id, + provider: "telegram", + slug: string, + name: string, + userId: Id, +} + // For this demo I'm just storing the runtime data in JSON files. When making // this into proper application this should be replaced with an actual database. @@ -37,6 +48,8 @@ const usersPath = "data/users.json"; const nextUserIdPath = "data/next-user-id.json"; const sessionsPath = "data/sessions.json"; const nextSessionIdPath = "data/next-session-id.json"; +const authMethodPath = "data/auth-method.json"; +const nextAuthenticationMethodIdPath = "data/auth-method-id.json" async function remove(path: string) { try { @@ -137,3 +150,17 @@ export async function readSessions() { export async function writeSessions(sessions: ServerSession[]) { await writeFile(sessionsPath, JSON.stringify(sessions, undefined, "\t") + "\n", "utf-8"); } + +export async function nextAuthenticationMethodId() { + const nextId = await readJson(nextAuthenticationMethodIdPath, 0); + await writeFile(nextAuthenticationMethodIdPath, String(nextId + 1), "utf-8"); + return nextId; +} + +export async function readAuthenticationMethods() { + return readJson(authMethodPath, []) +} + +export async function writeAuthenticationMethods(authMethods: ServerAuthenticationMethod[]) { + await writeFile(authMethodPath, JSON.stringify(authMethods, undefined, "\t") + "\n", "utf-8"); +} diff --git a/server/utils/session.ts b/server/utils/session.ts index f89a6b6..9c824e7 100644 --- a/server/utils/session.ts +++ b/server/utils/session.ts @@ -51,7 +51,13 @@ export async function clearServerSession(event: H3Event) { deleteCookie(event, "session"); } -export async function setServerSession(event: H3Event, account: ServerUser) { +export async function setServerSession( + event: H3Event, + account: ServerUser | undefined, + authenticationProvider?: "telegram", + authenticationSlug?: string, + authenticationName?: string, +) { const sessions = await readSessions(); const runtimeConfig = useRuntimeConfig(event); await clearServerSessionInternal(event, sessions); @@ -60,6 +66,9 @@ export async function setServerSession(event: H3Event, account: ServerUser) { const newSession: ServerSession = { access: account?.type ?? "anonymous", accountId: account?.id, + authenticationProvider, + authenticationSlug, + authenticationName, rotatesAtMs: now + runtimeConfig.sessionRotatesTimeout * 1000, discardAtMs: now + runtimeConfig.sessionDiscardTimeout * 1000, id: await nextSessionId(), @@ -68,6 +77,7 @@ export async function setServerSession(event: H3Event, account: ServerUser) { sessions.push(newSession); await writeSessions(sessions); await setSignedCookie(event, "session", String(newSession.id), runtimeConfig.sessionDiscardTimeout) + return newSession; } async function rotateSession(event: H3Event, sessions: ServerSession[], session: ServerSession) { @@ -78,6 +88,7 @@ async function rotateSession(event: H3Event, sessions: ServerSession[], session: const newSession: ServerSession = { accountId: account?.id, access: account?.type ?? "anonymous", + // Authentication provider is removed to avoid possibility of an infinite delay before using it. rotatesAtMs: now + runtimeConfig.sessionRotatesTimeout * 1000, discardAtMs: now + runtimeConfig.sessionDiscardTimeout * 1000, id: await nextSessionId(), @@ -172,6 +183,8 @@ export async function serverSessionToApi(event: H3Event, session: ServerSession) return { id: session.id, account, + authenticationProvider: session.authenticationProvider, + authenticationName: session.authenticationName, push, }; } diff --git a/shared/types/api.ts b/shared/types/api.ts index 3f2d421..64812ac 100644 --- a/shared/types/api.ts +++ b/shared/types/api.ts @@ -70,6 +70,8 @@ export type ApiSubscription = z.infer; export interface ApiSession { id: Id, account?: ApiAccount, + authenticationProvider?: "telegram", + authenticationName?: string, push: boolean, } diff --git a/shared/types/telegram.ts b/shared/types/telegram.ts new file mode 100644 index 0000000..b7caeb7 --- /dev/null +++ b/shared/types/telegram.ts @@ -0,0 +1,20 @@ +/* + SPDX-FileCopyrightText: © 2025 Hornwitser + SPDX-License-Identifier: AGPL-3.0-or-later +*/ +import { z } from "zod/v4-mini"; + +export const telegramAuthDataSchema = z.catchall( + z.object({ + // These fields are pure speculation as the actual API is undocumented. + auth_date: z.number(), + first_name: z.optional(z.string()), + hash: z.string(), + id: z.number(), + last_name: z.optional(z.string()), + photo_url: z.optional(z.string()), + username: z.optional(z.string()), + }), + z.union([z.string(), z.number()]), +); +export type TelegramAuthData = z.infer; diff --git a/stores/session.ts b/stores/session.ts index 2545c4f..ddba323 100644 --- a/stores/session.ts +++ b/stores/session.ts @@ -26,6 +26,8 @@ const fetchSessionWithCookie = async (event?: H3Event) => { export const useSessionStore = defineStore("session", () => { const state = { account: ref(), + authenticationProvider: ref(), + authenticationName: ref(), id: ref(), push: ref(false), }; @@ -37,6 +39,8 @@ export const useSessionStore = defineStore("session", () => { }, update(session?: ApiSession) { state.account.value = session?.account; + state.authenticationProvider.value = session?.authenticationProvider; + state.authenticationName.value = session?.authenticationName; state.id.value = session?.id; state.push.value = session?.push ?? false; },