diff --git a/server/components/Auth/OpenID.vue b/server/components/Auth/OpenID.vue new file mode 100644 index 00000000..926e1103 --- /dev/null +++ b/server/components/Auth/OpenID.vue @@ -0,0 +1,10 @@ + diff --git a/server/components/Auth/Simple.vue b/server/components/Auth/Simple.vue new file mode 100644 index 00000000..c248f2f0 --- /dev/null +++ b/server/components/Auth/Simple.vue @@ -0,0 +1,124 @@ + + + diff --git a/server/components/UserHeader/NotificationWidgetPanel.vue b/server/components/UserHeader/NotificationWidgetPanel.vue index 280f50fa..78b89828 100644 --- a/server/components/UserHeader/NotificationWidgetPanel.vue +++ b/server/components/UserHeader/NotificationWidgetPanel.vue @@ -22,7 +22,7 @@
- + + + + Signout + +
@@ -86,10 +98,5 @@ const navigation: NavigationItem[] = [ route: "/account", prefix: "", }, - { - label: "Sign out", - route: "/auth/signout", - prefix: "", - }, ].filter((e) => e !== undefined); diff --git a/server/drop-base b/server/drop-base index e32cc36f..a14d1b70 160000 --- a/server/drop-base +++ b/server/drop-base @@ -1 +1 @@ -Subproject commit e32cc36f3396d2f90448742fccb33854460a893c +Subproject commit a14d1b7081cf2e6aa5174e3cfd7b7fe6904ab7bf diff --git a/server/package.json b/server/package.json index 43e321f9..5489fb35 100644 --- a/server/package.json +++ b/server/package.json @@ -58,6 +58,7 @@ "eslint": "^9.24.0", "eslint-config-prettier": "^10.1.1", "h3": "^1.15.1", + "ofetch": "^1.4.1", "postcss": "^8.4.47", "prettier": "^3.5.3", "sass": "^1.79.4", diff --git a/server/pages/auth/register.vue b/server/pages/auth/register.vue index 1abf5ed9..3ee5bd41 100644 --- a/server/pages/auth/register.vue +++ b/server/pages/auth/register.vue @@ -56,7 +56,7 @@ type="email" autocomplete="email" required - :disabled="!!invitation.data.value?.email" + :disabled="!!invitation?.email" placeholder="me@example.com" class="block w-full rounded-md border-0 py-1.5 px-3 bg-zinc-800 disabled:bg-zinc-900/80 text-zinc-100 disabled:text-zinc-400 shadow-sm ring-1 ring-inset ring-zinc-700 disabled:ring-zinc-800 placeholder:text-zinc-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6" /> @@ -87,7 +87,7 @@ type="text" autocomplete="username" required - :disabled="!!invitation.data.value?.username" + :disabled="!!invitation?.username" placeholder="myUsername" class="block w-full rounded-md border-0 py-1.5 px-3 bg-zinc-800 disabled:bg-zinc-900/80 text-zinc-100 disabled:text-zinc-400 shadow-sm ring-1 ring-inset ring-zinc-700 disabled:ring-zinc-800 placeholder:text-zinc-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6" /> @@ -199,13 +199,13 @@ if (!invitationId) statusMessage: "Invitation required to sign up.", }); -const invitation = await useFetch( +const invitation = await $dropFetch( `/api/v1/auth/signup/simple?id=${encodeURIComponent(invitationId)}`, ); -const email = ref(invitation.data.value?.email); +const email = ref(invitation?.email); const displayName = ref(""); -const username = ref(invitation.data.value?.username); +const username = ref(invitation?.username); const password = ref(""); const confirmPassword = ref(undefined); diff --git a/server/pages/auth/signin.vue b/server/pages/auth/signin.vue index 9d9dd4f8..d833a4c4 100644 --- a/server/pages/auth/signin.vue +++ b/server/pages/auth/signin.vue @@ -18,92 +18,13 @@
-
-
- -
- -
-
- -
- -
- -
-
- -
-
- - -
- -
- Forgot password? -
-
- -
- - Sign in -
- -
-
-
-
-
-

- {{ error }} -

-
-
-
-
+ +
+ + OR + +
+
@@ -119,47 +40,9 @@ diff --git a/server/prisma/auth.prisma b/server/prisma/auth.prisma index d1253cba..2ff00de0 100644 --- a/server/prisma/auth.prisma +++ b/server/prisma/auth.prisma @@ -1,5 +1,6 @@ enum AuthMec { Simple + OpenID } model LinkedAuthMec { diff --git a/server/prisma/migrations/20250507120031_add_openid_authmek/migration.sql b/server/prisma/migrations/20250507120031_add_openid_authmek/migration.sql new file mode 100644 index 00000000..c48e6a00 --- /dev/null +++ b/server/prisma/migrations/20250507120031_add_openid_authmek/migration.sql @@ -0,0 +1,2 @@ +-- AlterEnum +ALTER TYPE "AuthMec" ADD VALUE 'OpenID'; diff --git a/server/prisma/migrations/migration_lock.toml b/server/prisma/migrations/migration_lock.toml index 648c57fd..044d57cd 100644 --- a/server/prisma/migrations/migration_lock.toml +++ b/server/prisma/migrations/migration_lock.toml @@ -1,3 +1,3 @@ # Please do not edit this file manually # It should be added in your version-control system (e.g., Git) -provider = "postgresql" \ No newline at end of file +provider = "postgresql" diff --git a/server/server/api/v1/auth/index.get.ts b/server/server/api/v1/auth/index.get.ts new file mode 100644 index 00000000..34e71cae --- /dev/null +++ b/server/server/api/v1/auth/index.get.ts @@ -0,0 +1,9 @@ +import { enabledAuthManagers } from "~/server/plugins/04.auth-init"; + +export default defineEventHandler((h3) => { + const authManagers = Object.entries(enabledAuthManagers) + .filter((e) => !!e[1]) + .map((e) => e[0]); + + return authManagers; +}); diff --git a/server/server/api/v1/auth/signin/simple.post.ts b/server/server/api/v1/auth/signin/simple.post.ts index 08e0efc9..04f3f7f8 100644 --- a/server/server/api/v1/auth/signin/simple.post.ts +++ b/server/server/api/v1/auth/signin/simple.post.ts @@ -7,6 +7,7 @@ import { checkHashBcrypt, } from "~/server/internal/security/simple"; import sessionHandler from "~/server/internal/session"; +import { enabledAuthManagers } from "~/server/plugins/04.auth-init"; const signinValidator = type({ username: "string", @@ -15,6 +16,12 @@ const signinValidator = type({ }); export default defineEventHandler(async (h3) => { + if (!enabledAuthManagers.simple) + throw createError({ + statusCode: 403, + statusMessage: "Sign in method not enabled", + }); + const body = signinValidator(await readBody(h3)); if (body instanceof type.errors) { // hover out.summary to see validation errors diff --git a/server/server/internal/P2P.md b/server/server/internal/P2P.md deleted file mode 100644 index 9da9a822..00000000 --- a/server/server/internal/P2P.md +++ /dev/null @@ -1,15 +0,0 @@ -# Drop P2P System - -Drop clients have a variety of P2P or P2P-like methods of data transfer available - -## Public (not quite) HTTPS downloads endpoints - -These use public HTTPS certificate, and while are authenticated, are 'public' in the sense that they aren't P2P; anyone can connect to them - -## Private mTLS P2P endpoints - -Drop clients use P2P mTLS aided by the P2P co-ordinator to transfer chunks between themselves. This happens over HTTP. - -## Private mTLS Wireguard tunnels - -Drop clients can establish P2P Wireguard diff --git a/server/server/internal/clients/ca-store.ts b/server/server/internal/clients/ca-store.ts index 15fe49ec..fc7f1c65 100644 --- a/server/server/internal/clients/ca-store.ts +++ b/server/server/internal/clients/ca-store.ts @@ -70,14 +70,17 @@ export const dbCertificateStore = () => { }; }, async blacklistCertificate(name: string) { - await prisma.certificate.update({ - where: { - id: name, - }, - data: { - blacklisted: true, - }, - }); + try { + await prisma.certificate.update({ + where: { + id: name, + }, + data: { + blacklisted: true, + }, + }); + } finally { + } }, async checkBlacklistCertificate(name: string): Promise { const result = await prisma.certificate.findUnique({ @@ -88,7 +91,7 @@ export const dbCertificateStore = () => { blacklisted: true, }, }); - if (result === null) return false; + if (result === null) return true; return result.blacklisted; }, }; diff --git a/server/server/internal/oidc/index.ts b/server/server/internal/oidc/index.ts new file mode 100644 index 00000000..dbcf7a84 --- /dev/null +++ b/server/server/internal/oidc/index.ts @@ -0,0 +1,281 @@ +import { randomUUID } from "crypto"; +import prisma from "../db/database"; +import { AuthMec, Prisma } from "@prisma/client"; +import objectHandler from "../objects"; +import { Readable } from "stream"; +import * as jdenticon from "jdenticon"; + +interface OIDCWellKnown { + authorization_endpoint: string; + token_endpoint: string; + userinfo_endpoint: string; + scopes_supported: string[]; +} + +interface OIDCAuthSession { + redirectUrl: string; + callbackUrl: string; + state: string; +} + +interface OIDCUserInfo { + sub: string; + name?: string; + preferred_username?: string; + picture?: string; + email?: string; + groups?: Array; +} + +export interface OIDCAuthMekCredentialsV1 { + sub: string; +} + +export class OIDCManager { + private oidcConfiguration: OIDCWellKnown; + private clientId: string; + private clientSecret: string; + private externalUrl: string; + + private adminGroup?: string = process.env.OIDC_ADMIN_GROUP; + private usernameClaim: keyof OIDCUserInfo = + (process.env.OIDC_USERNAME_CLAIM as any) ?? "preferred_username"; + + private signinStateTable: { [key: string]: OIDCAuthSession } = {}; + + constructor( + oidcConfiguration: OIDCWellKnown, + clientId: string, + clientSecret: string, + externalUrl: string, + ) { + this.oidcConfiguration = oidcConfiguration; + this.clientId = clientId; + this.clientSecret = clientSecret; + this.externalUrl = externalUrl; + } + + async create() { + const wellKnownUrl = process.env.OIDC_WELLKNOWN as string | undefined; + let configuration: OIDCWellKnown; + if (wellKnownUrl) { + const response: OIDCWellKnown = await $fetch(wellKnownUrl); + if ( + !response.authorization_endpoint || + !response.scopes_supported || + !response.token_endpoint || + !response.userinfo_endpoint + ) { + throw new Error("Well known response was invalid"); + } + + configuration = response; + } else { + const authorizationEndpoint = process.env.OIDC_AUTHORIZATION as + | string + | undefined; + const tokenEndpoint = process.env.OIDC_TOKEN as string | undefined; + const userinfoEndpoint = process.env.OIDC_USERINFO as string | undefined; + const scopes = process.env.OIDC_SCOPES as string | undefined; + + if ( + !authorizationEndpoint || + !tokenEndpoint || + !userinfoEndpoint || + !scopes + ) { + const debugObject = { + OIDC_AUTHORIZATION: authorizationEndpoint, + OIDC_TOKEN: tokenEndpoint, + OIDC_USERINFO: userinfoEndpoint, + OIDC_SCOPES: scopes, + }; + throw new Error( + "Missing all necessary OIDC configuration: \n" + + Object.entries(debugObject) + .map(([k, v]) => ` ${k}: ${v}`) + .join("\n"), + ); + } + + configuration = { + authorization_endpoint: authorizationEndpoint, + token_endpoint: tokenEndpoint, + userinfo_endpoint: userinfoEndpoint, + scopes_supported: scopes.split(","), + }; + } + + if (!configuration) + throw new Error("OIDC try to init without configuration"); + + const clientId = process.env.OIDC_CLIENT_ID as string | undefined; + const clientSecret = process.env.OIDC_CLIENT_SECRET as string | undefined; + const externalUrl = process.env.EXTERNAL_URL as string | undefined; + + if (!clientId || !clientSecret) + throw new Error("Missing client ID or secret for OIDC"); + + if (!externalUrl) throw new Error("EXTERNAL_URL required for OIDC"); + + return new OIDCManager(configuration, clientId, clientSecret, externalUrl); + } + + generateAuthSession(): OIDCAuthSession { + const stateKey = randomUUID(); + + const normalisedUrl = new URL( + this.oidcConfiguration.authorization_endpoint, + ).toString(); + const redirectNormalisedUrl = new URL(this.externalUrl).toString(); + + const redirectUrl = `${redirectNormalisedUrl}auth/callback/oidc`; + + const finalUrl = `${normalisedUrl}?client_id=${this.clientId}&redirect_uri=${encodeURIComponent(redirectUrl)}&state=${stateKey}&response_type=code&scope=${encodeURIComponent(this.oidcConfiguration.scopes_supported.join(" "))}`; + + const session: OIDCAuthSession = { + redirectUrl: finalUrl, + callbackUrl: redirectUrl, + state: stateKey, + }; + this.signinStateTable[stateKey] = session; + return session; + } + + async authorize(code: string, state: string) { + const session = this.signinStateTable[state]; + if (!session) return "Invalid state parameter"; + + const tokenEndpoint = new URL( + this.oidcConfiguration.token_endpoint, + ).toString(); + const userinfoEndpoint = new URL( + this.oidcConfiguration.userinfo_endpoint, + ).toString(); + + const requestBody = new URLSearchParams({ + client_id: this.clientId, + client_secret: this.clientSecret, + grant_type: "authorization_code", + code: code, + redirect_uri: session.callbackUrl, + scope: this.oidcConfiguration.scopes_supported.join(","), + }); + + try { + const { access_token, token_type } = await $fetch<{ + access_token: string; + token_type: string; + id_token: string; + }>(tokenEndpoint, { + body: requestBody, + method: "POST", + }); + + const userinfo = await $fetch(userinfoEndpoint, { + headers: { + Authorization: `${token_type} ${access_token}`, + }, + }); + + const user = await this.fetchOrCreateUser(userinfo); + + return user; + } catch (e) { + console.error(e); + return `Request to identity provider failed: ${e}`; + } + } + + async fetchOrCreateUser(userinfo: OIDCUserInfo) { + const existingAuthMek = await prisma.linkedAuthMec.findFirst({ + where: { + mec: AuthMec.OpenID, + version: 1, + credentials: { + path: ["sub"], + equals: userinfo.sub, + }, + }, + include: { + user: true, + }, + }); + + if (existingAuthMek) return existingAuthMek.user; + + const username = userinfo[this.usernameClaim]?.toString(); + if (!username) + return "Invalid username claim in OIDC response: " + this.usernameClaim; + + /* + const takenUsername = await prisma.user.count({ + where: { + username, + }, + }); + + if (takenUsername > 0) + return "Username already taken. Please contact your server admin."; + */ + + const creds: OIDCAuthMekCredentialsV1 = { + sub: userinfo.sub, + }; + + const userId = randomUUID(); + const profilePictureId = randomUUID(); + + if (userinfo.picture) { + await objectHandler.createFromSource( + profilePictureId, + async () => + await $fetch(userinfo.picture!!, { + responseType: "stream", + }), + {}, + [`internal:read`, `${userId}:read`], + ); + } else { + await objectHandler.createFromSource( + profilePictureId, + async () => jdenticon.toPng(userinfo.sub, 256), + {}, + [`internal:read`, `${userId}:read`], + ); + } + + const isAdmin = + userinfo.groups !== undefined && + this.adminGroup !== undefined && + userinfo.groups.includes(this.adminGroup); + + const created = await prisma.linkedAuthMec.create({ + data: { + mec: AuthMec.OpenID, + version: 1, + user: { + connectOrCreate: { + where: { + username, + }, + create: { + id: userId, + username, + email: userinfo.email ?? "", + displayName: userinfo.name ?? username, + profilePicture: profilePictureId, + admin: isAdmin, + }, + }, + }, + credentials: creds as any, // Prisma converts this to the Json type for us + }, + include: { + user: true, + }, + }); + + return created.user; + } +} diff --git a/server/server/plugins/04.auth-init.ts b/server/server/plugins/04.auth-init.ts new file mode 100644 index 00000000..6b561eb1 --- /dev/null +++ b/server/server/plugins/04.auth-init.ts @@ -0,0 +1,37 @@ +import { OIDCManager } from "../internal/oidc"; + +export const enabledAuthManagers: { + simple: boolean; + oidc: OIDCManager | undefined; +} = { + simple: false, + oidc: undefined, +}; + +const initFunctions: { + [K in keyof typeof enabledAuthManagers]: () => Promise; +} = { + oidc: OIDCManager.prototype.create, + simple: async () => { + const disabled = process.env.DISABLE_SIMPLE_AUTH as string | undefined; + return !disabled; + }, +}; + +export default defineNitroPlugin(async (nitro) => { + for (const [key, init] of Object.entries(initFunctions)) { + try { + const object = await init(); + if (!object) break; + (enabledAuthManagers as any)[key] = object; + console.log(`enabled auth: ${key}`); + } catch (e) { + console.warn(e); + } + } + + // Add every other auth mechanism here, and fall back to simple if none of them are enabled + if (!enabledAuthManagers.oidc) { + enabledAuthManagers.simple = true; + } +}); diff --git a/server/server/plugins/redirect.ts b/server/server/plugins/redirect.ts index b892dc83..8218a0cb 100644 --- a/server/server/plugins/redirect.ts +++ b/server/server/plugins/redirect.ts @@ -7,6 +7,7 @@ export default defineNitroPlugin((nitro) => { // Don't handle for API routes if (event.path.startsWith("/api")) return; + if (event.path.startsWith("/auth")) return; // Make sure it's a web error if (!(error instanceof H3Error)) return; diff --git a/server/server/routes/auth/callback/oidc.get.ts b/server/server/routes/auth/callback/oidc.get.ts new file mode 100644 index 00000000..8d2d5ad3 --- /dev/null +++ b/server/server/routes/auth/callback/oidc.get.ts @@ -0,0 +1,36 @@ +import sessionHandler from "~/server/internal/session"; +import { enabledAuthManagers } from "~/server/plugins/04.auth-init"; + +export default defineEventHandler(async (h3) => { + if (!enabledAuthManagers.oidc) return sendRedirect(h3, "/auth/signin"); + + const manager = enabledAuthManagers.oidc; + + const query = getQuery(h3); + const code = query.code?.toString(); + if (!code) + throw createError({ + statusCode: 400, + statusMessage: "No code in query params.", + }); + + const state = query.state?.toString(); + if (!state) + throw createError({ + statusCode: 400, + statusMessage: "No state in query params.", + }); + + const user = await manager.authorize(code, state); + + if (typeof user === "string") + throw createError({ + statusCode: 403, + statusMessage: `Failed to sign in: "${user}". Please try again.`, + }); + + + await sessionHandler.signin(h3, user.id, true); + + return sendRedirect(h3, "/"); +}); diff --git a/server/server/routes/auth/oidc.get.ts b/server/server/routes/auth/oidc.get.ts new file mode 100644 index 00000000..dda26d68 --- /dev/null +++ b/server/server/routes/auth/oidc.get.ts @@ -0,0 +1,10 @@ +import { enabledAuthManagers } from "~/server/plugins/04.auth-init"; + +export default defineEventHandler((h3) => { + if (!enabledAuthManagers.oidc) return sendRedirect(h3, "/auth/signin"); + + const manager = enabledAuthManagers.oidc; + const { redirectUrl } = manager.generateAuthSession(); + + return sendRedirect(h3, redirectUrl); +}); diff --git a/server/server/routes/signout.get.ts b/server/server/routes/auth/signout.get.ts similarity index 71% rename from server/server/routes/signout.get.ts rename to server/server/routes/auth/signout.get.ts index 08e7673e..ebeb6f74 100644 --- a/server/server/routes/signout.get.ts +++ b/server/server/routes/auth/signout.get.ts @@ -1,4 +1,4 @@ -import sessionHandler from "../internal/session"; +import sessionHandler from "../../internal/session"; export default defineEventHandler(async (h3) => { await sessionHandler.signout(h3);