diff --git a/README.md b/README.md index bb5a4853..7dcb6dd8 100755 --- a/README.md +++ b/README.md @@ -118,11 +118,11 @@ OAUTH_GITHUB_CLIENT_SECRET=x OAUTH_GOOGLE_CLIENT_ID=x-x.apps.googleusercontent.com OAUTH_GOOGLE_CLIENT_SECRET=x-x-x -OAUTH_AUTHENTIK_CLIENT_ID=x -OAUTH_AUTHENTIK_CLIENT_SECRET=x -OAUTH_AUTHENTIK_AUTHORIZE_URL=http://localhost:9000/application/o/authorize/ -OAUTH_AUTHENTIK_USERINFO_URL=http://localhost:9000/application/o/userinfo/ -OAUTH_AUTHENTIK_TOKEN_URL=http://localhost:9000/application/o/token/ +OAUTH_OIDC_CLIENT_ID=x +OAUTH_OIDC_CLIENT_SECRET=x +OAUTH_OIDC_AUTHORIZE_URL=http://localhost:9000/application/o/authorize/ +OAUTH_OIDC_USERINFO_URL=http://localhost:9000/application/o/userinfo/ +OAUTH_OIDC_TOKEN_URL=http://localhost:9000/application/o/token/ FEATURES_OAUTH_REGISTRATION=true FEATURES_USER_REGISTRATION=true diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 69ecb664..7e335489 100755 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -29,6 +29,7 @@ model User { totpSecret String? passkeys UserPasskey[] + sessions String[] quota UserQuota? @@ -103,7 +104,7 @@ enum OAuthProviderType { DISCORD GOOGLE GITHUB - AUTHENTIK + OIDC } model File { @@ -112,7 +113,7 @@ model File { updatedAt DateTime @updatedAt deletesAt DateTime? - name String // name & file saved on datasource + name String // name & file saved on datasource originalName String? // original name of file when uploaded size BigInt type String @@ -232,4 +233,4 @@ model Invite { inviter User @relation(fields: [inviterId], references: [id], onDelete: Cascade, onUpdate: Cascade) inviterId String -} +} \ No newline at end of file diff --git a/src/components/pages/settings/parts/SettingsOAuth/index.tsx b/src/components/pages/settings/parts/SettingsOAuth/index.tsx index b3a23c5f..4835ba07 100755 --- a/src/components/pages/settings/parts/SettingsOAuth/index.tsx +++ b/src/components/pages/settings/parts/SettingsOAuth/index.tsx @@ -24,14 +24,14 @@ const icons = { DISCORD: , GITHUB: , GOOGLE: , - AUTHENTIK: , + OIDC: , }; const names = { DISCORD: 'Discord', GITHUB: 'GitHub', GOOGLE: 'Google', - AUTHENTIK: 'Authentik', + OIDC: 'OpenID Connect', }; function OAuthButton({ provider, linked }: { provider: OAuthProviderType; linked: boolean }) { @@ -90,7 +90,7 @@ export default function SettingsOAuth() { const discordLinked = findProvider('DISCORD', user?.oauthProviders ?? []); const githubLinked = findProvider('GITHUB', user?.oauthProviders ?? []); const googleLinked = findProvider('GOOGLE', user?.oauthProviders ?? []); - const authentikLinked = findProvider('AUTHENTIK', user?.oauthProviders ?? []); + const oidcLinked = findProvider('OIDC', user?.oauthProviders ?? []); return ( @@ -103,7 +103,7 @@ export default function SettingsOAuth() { {config.oauthEnabled.discord && } {config.oauthEnabled.github && } {config.oauthEnabled.google && } - {config.oauthEnabled.authentik && } + {config.oauthEnabled.oidc && } ); diff --git a/src/lib/config/read.ts b/src/lib/config/read.ts index 74ca82cb..cc73c171 100755 --- a/src/lib/config/read.ts +++ b/src/lib/config/read.ts @@ -102,7 +102,7 @@ export const rawConfig: any = { clientId: undefined, clientSecret: undefined, }, - authentik: { + oidc: { clientId: undefined, clientSecret: undefined, authorizeUrl: undefined, @@ -211,11 +211,11 @@ export const PROP_TO_ENV = { 'oauth.github.clientSecret': 'OAUTH_GITHUB_CLIENT_SECRET', 'oauth.google.clientId': 'OAUTH_GOOGLE_CLIENT_ID', 'oauth.google.clientSecret': 'OAUTH_GOOGLE_CLIENT_SECRET', - 'oauth.authentik.clientId': 'OAUTH_AUTHENTIK_CLIENT_ID', - 'oauth.authentik.clientSecret': 'OAUTH_AUTHENTIK_CLIENT_SECRET', - 'oauth.authentik.authorizeUrl': 'OAUTH_AUTHENTIK_AUTHORIZE_URL', - 'oauth.authentik.userinfoUrl': 'OAUTH_AUTHENTIK_USERINFO_URL', - 'oauth.authentik.tokenUrl': 'OAUTH_AUTHENTIK_TOKEN_URL', + 'oauth.oidc.clientId': 'OAUTH_OIDC_CLIENT_ID', + 'oauth.oidc.clientSecret': 'OAUTH_OIDC_CLIENT_SECRET', + 'oauth.oidc.authorizeUrl': 'OAUTH_OIDC_AUTHORIZE_URL', + 'oauth.oidc.userinfoUrl': 'OAUTH_OIDC_USERINFO_URL', + 'oauth.oidc.tokenUrl': 'OAUTH_OIDC_TOKEN_URL', 'discord.webhookUrl': 'DISCORD_WEBHOOK_URL', 'discord.username': 'DISCORD_USERNAME', @@ -339,11 +339,11 @@ export function readEnv() { env('oauth.github.clientSecret', 'string'), env('oauth.google.clientId', 'string'), env('oauth.google.clientSecret', 'string'), - env('oauth.authentik.clientId', 'string'), - env('oauth.authentik.clientSecret', 'string'), - env('oauth.authentik.authorizeUrl', 'string'), - env('oauth.authentik.userinfoUrl', 'string'), - env('oauth.authentik.tokenUrl', 'string'), + env('oauth.oidc.clientId', 'string'), + env('oauth.oidc.clientSecret', 'string'), + env('oauth.oidc.authorizeUrl', 'string'), + env('oauth.oidc.userinfoUrl', 'string'), + env('oauth.oidc.tokenUrl', 'string'), env('discord.webhookUrl', 'string'), env('discord.username', 'string'), diff --git a/src/lib/config/validate.ts b/src/lib/config/validate.ts index b5b74675..3f897362 100755 --- a/src/lib/config/validate.ts +++ b/src/lib/config/validate.ts @@ -242,7 +242,7 @@ export const schema = z.object({ clientSecret: z.undefined(), }), ), - authentik: z + oidc: z .object({ clientId: z.string(), clientSecret: z.string(), diff --git a/src/lib/db/models/user.ts b/src/lib/db/models/user.ts index 5b489a8c..66e8d22f 100755 --- a/src/lib/db/models/user.ts +++ b/src/lib/db/models/user.ts @@ -9,6 +9,8 @@ export type User = { role: 'USER' | 'ADMIN' | 'SUPERADMIN'; view: UserViewSettings; + sessions: string[]; + oauthProviders: OAuthProvider[]; totpSecret?: string | null; @@ -32,6 +34,7 @@ export const userSelect = { totpSecret: true, passkeys: true, quota: true, + sessions: true, }; export type UserViewSettings = z.infer; diff --git a/src/lib/middleware/ziplineAuth.ts b/src/lib/middleware/ziplineAuth.ts index bfe8aef1..4d6edced 100755 --- a/src/lib/middleware/ziplineAuth.ts +++ b/src/lib/middleware/ziplineAuth.ts @@ -6,6 +6,7 @@ import { User, userSelect } from '../db/models/user'; import { NextApiReq, NextApiRes } from '../response'; import { Handler } from './combine'; import { isAdministrator } from '../role'; +import { getSession } from '@/server/session'; export type ZiplineAuthOptions = { administratorOnly?: boolean; @@ -47,28 +48,19 @@ export function parseUserToken( export function ziplineAuth(options?: ZiplineAuthOptions) { return (handler: Handler) => { return async (req: NextApiReq, res: NextApiRes) => { - let rawToken: string | undefined; - - if (req.cookies.zipline_token) rawToken = req.cookies.zipline_token; - else if (req.headers.authorization) rawToken = req.headers.authorization; - - try { - // eslint-disable-next-line no-var - var token = parseUserToken(rawToken); - } catch (e) { - return res.unauthorized((e as { error: string }).error); - } + const session = await getSession(req, res); + if (!session.id || !session.sessionId) return res.unauthorized('invalid session, not logged in'); const user = await prisma.user.findFirst({ where: { - token, - }, - select: { - ...userSelect, - ...(options?.select && options.select), + sessions: { + has: session.sessionId, + }, }, + select: userSelect, }); - if (!user) return res.unauthorized(); + + if (!user) return res.unauthorized('invalid login session'); req.user = user; diff --git a/src/lib/oauth/enabled.ts b/src/lib/oauth/enabled.ts index f3f6dfdf..96bacf4b 100755 --- a/src/lib/oauth/enabled.ts +++ b/src/lib/oauth/enabled.ts @@ -20,12 +20,12 @@ export default function enabled(config: Config) { config.features.oauthRegistration, ); - const authentikEnabled = isTruthy( - config.oauth?.authentik?.clientId, - config.oauth?.authentik?.clientSecret, - config.oauth?.authentik?.authorizeUrl, - config.oauth?.authentik?.tokenUrl, - config.oauth?.authentik?.userinfoUrl, + const oidcEnabled = isTruthy( + config.oauth?.oidc?.clientId, + config.oauth?.oidc?.clientSecret, + config.oauth?.oidc?.authorizeUrl, + config.oauth?.oidc?.tokenUrl, + config.oauth?.oidc?.userinfoUrl, config.features.oauthRegistration, ); @@ -33,6 +33,6 @@ export default function enabled(config: Config) { discord: discordEnabled, github: githubEnabled, google: googleEnabled, - authentik: authentikEnabled, + oidc: oidcEnabled, }; } diff --git a/src/lib/oauth/providerUtil.ts b/src/lib/oauth/providerUtil.ts index 31e1d5e0..6d06e3a3 100755 --- a/src/lib/oauth/providerUtil.ts +++ b/src/lib/oauth/providerUtil.ts @@ -61,10 +61,10 @@ export const googleAuth = { }, }; -export const authentikAuth = { +export const oidcAuth = { url: (clientId: string, origin: string, authorizeUrl: string, state?: string) => `${authorizeUrl}?client_id=${clientId}&redirect_uri=${encodeURIComponent( - `${origin}/api/auth/oauth/authentik`, + `${origin}/api/auth/oauth/oidc`, )}&response_type=code&scope=openid+email+profile+offline_access${state ? `&state=${state}` : ''}`, user: async (accessToken: string, userInfoUrl: string) => { const res = await fetch(userInfoUrl, { diff --git a/src/lib/oauth/withOAuth.ts b/src/lib/oauth/withOAuth.ts index 4535ee3a..bfed27be 100755 --- a/src/lib/oauth/withOAuth.ts +++ b/src/lib/oauth/withOAuth.ts @@ -1,13 +1,12 @@ import { NextApiReq, NextApiRes } from '@/lib/response'; import { OAuthProviderType } from '@prisma/client'; import { prisma } from '../db'; -import { parseUserToken } from '../middleware/ziplineAuth'; import { findProvider } from './providerUtil'; import { createToken } from '../crypto'; import { config } from '../config'; import { User } from '../db/models/user'; import Logger, { log } from '../logger'; -import { getSession } from '@/server/session'; +import { getSession, saveSession } from '@/server/session'; export interface OAuthQuery { state?: string; @@ -76,15 +75,11 @@ export const withOAuth = const { state } = req.query as OAuthQuery; - let rawToken: string | undefined; - - if (req.cookies.zipline_token) rawToken = req.cookies.zipline_token; - else if (req.headers.authorization) rawToken = req.headers.authorization; - const token = parseUserToken(rawToken, true); - const user = await prisma.user.findFirst({ where: { - token: token ?? '', + sessions: { + has: session.sessionId ?? '', + }, }, include: { oauthProviders: true, @@ -94,7 +89,7 @@ export const withOAuth = const userOauth = findProvider(provider, user?.oauthProviders ?? []); if (state === 'link') { - if (!user) return res.unauthorized(); + if (!user) return res.unauthorized('invalid session'); if (findProvider(provider, user.oauthProviders)) return res.badRequest('This account is already linked to this provider'); @@ -122,8 +117,7 @@ export const withOAuth = }, }); - session.user = user; - await session.save(); + await saveSession(session, user); logger.info('linked oauth account', { provider, @@ -153,8 +147,7 @@ export const withOAuth = }, }); - session.user = user; - await session.save(); + await saveSession(session, user); return res.redirect('/dashboard'); } else if (existingOauth) { @@ -173,8 +166,7 @@ export const withOAuth = }, }); - session.user = login.user! as User; - await session.save(); + await saveSession(session, login.user!); logger.info('logged in with oauth', { provider, @@ -206,8 +198,7 @@ export const withOAuth = }, }); - session.user = nuser as User; - await session.save(); + await saveSession(session, nuser); logger.info('created user with oauth', { provider, diff --git a/src/lib/theme/file.ts b/src/lib/theme/file.ts index 651bc569..c763eada 100755 --- a/src/lib/theme/file.ts +++ b/src/lib/theme/file.ts @@ -69,7 +69,7 @@ export async function handleOverrideColors(theme: ZiplineTheme) { ...theme.colors, google: theme.colors?.google || Array(10).fill('#4285F4'), github: theme.colors?.github || Array(10).fill('#24292E'), - authentik: theme.colors?.authentik || Array(10).fill('#FD4B2D'), + oidc: theme.colors?.oidc || Array(10).fill('#72abcf'), discord: theme.colors?.discord || Array(10).fill('#5865F2'), }, } as ZiplineTheme; diff --git a/src/pages/api/auth/oauth/authentik.ts b/src/pages/api/auth/oauth/oidc.ts old mode 100755 new mode 100644 similarity index 68% rename from src/pages/api/auth/oauth/authentik.ts rename to src/pages/api/auth/oauth/oidc.ts index 84ba0560..11ef087b --- a/src/pages/api/auth/oauth/authentik.ts +++ b/src/pages/api/auth/oauth/oidc.ts @@ -3,7 +3,7 @@ import Logger from '@/lib/logger'; import { combine } from '@/lib/middleware/combine'; import { method } from '@/lib/middleware/method'; import enabled from '@/lib/oauth/enabled'; -import { authentikAuth } from '@/lib/oauth/providerUtil'; +import { oidcAuth } from '@/lib/oauth/providerUtil'; import { OAuthQuery, OAuthResponse, withOAuth } from '@/lib/oauth/withOAuth'; // thanks to @danejur for this https://github.com/diced/zipline/pull/372 @@ -14,33 +14,33 @@ async function handler({ code, state, host }: OAuthQuery, _logger: Logger): Prom error_code: 403, }; - const { authentik: authentikEnabled } = enabled(config); + const { oidc: oidcEnabled } = enabled(config); - if (!authentikEnabled) + if (!oidcEnabled) return { - error: 'Authentik OAuth is not configured.', + error: 'OpenID Connect OAuth is not configured.', error_code: 401, }; if (!code) return { - redirect: authentikAuth.url( - config.oauth.authentik.clientId!, + redirect: oidcAuth.url( + config.oauth.oidc.clientId!, `${config.core.returnHttpsUrls ? 'https' : 'http'}://${host}`, - config.oauth.authentik.authorizeUrl!, + config.oauth.oidc.authorizeUrl!, state, ), }; const body = new URLSearchParams({ - client_id: config.oauth.authentik.clientId!, - client_secret: config.oauth.authentik.clientSecret!, + client_id: config.oauth.oidc.clientId!, + client_secret: config.oauth.oidc.clientSecret!, grant_type: 'authorization_code', code, - redirect_uri: `${config.core.returnHttpsUrls ? 'https' : 'http'}://${host}/api/auth/oauth/authentik`, + redirect_uri: `${config.core.returnHttpsUrls ? 'https' : 'http'}://${host}/api/auth/oauth/oidc`, }); - const res = await fetch(config.oauth.authentik.tokenUrl!, { + const res = await fetch(config.oauth.oidc.tokenUrl!, { method: 'POST', body, headers: { @@ -57,7 +57,7 @@ async function handler({ code, state, host }: OAuthQuery, _logger: Logger): Prom if (!json.access_token) return { error: 'No access token in response' }; if (!json.refresh_token) return { error: 'No refresh token in response' }; - const userJson = await authentikAuth.user(json.access_token, config.oauth.authentik.userinfoUrl!); + const userJson = await oidcAuth.user(json.access_token, config.oauth.oidc.userinfoUrl!); if (!userJson) return { error: 'Failed to fetch user' }; return { @@ -68,4 +68,4 @@ async function handler({ code, state, host }: OAuthQuery, _logger: Logger): Prom }; } -export default combine([method(['GET'])], withOAuth('AUTHENTIK', handler)); +export default combine([method(['GET'])], withOAuth('OIDC', handler)); diff --git a/src/pages/auth/login.tsx b/src/pages/auth/login.tsx index fb0e7077..f4b491f4 100755 --- a/src/pages/auth/login.tsx +++ b/src/pages/auth/login.tsx @@ -332,8 +332,8 @@ export default function Login({ config }: InferGetServerSidePropsType} /> )} - {config.oauthEnabled.authentik && ( - } /> + {config.oauthEnabled.oidc && ( + } /> )} diff --git a/src/server/middleware/user.ts b/src/server/middleware/user.ts index fb269df2..b676b580 100755 --- a/src/server/middleware/user.ts +++ b/src/server/middleware/user.ts @@ -41,11 +41,13 @@ export function parseUserToken( export async function userMiddleware(req: FastifyRequest, res: FastifyReply) { const session = await getSession(req, res); - if (!session.user) return res.unauthorized('not logged in'); + if (!session.id || !session.sessionId) return res.unauthorized('not logged in'); const user = await prisma.user.findFirst({ where: { - password: session.user.password, + sessions: { + has: session.sessionId, + }, }, select: userSelect, }); diff --git a/src/server/routes/api/auth/login.ts b/src/server/routes/api/auth/login.ts index 3a8ebca8..94ee7fe1 100755 --- a/src/server/routes/api/auth/login.ts +++ b/src/server/routes/api/auth/login.ts @@ -2,7 +2,7 @@ import { verifyPassword } from '@/lib/crypto'; import { prisma } from '@/lib/db'; import { User, userSelect } from '@/lib/db/models/user'; import { verifyTotpCode } from '@/lib/totp'; -import { getSession } from '@/server/session'; +import { getSession, saveSession } from '@/server/session'; import fastifyPlugin from 'fastify-plugin'; export type ApiLoginResponse = { @@ -27,7 +27,8 @@ export default fastifyPlugin( handler: async (req, res) => { const session = await getSession(req, res); - session.user = null; + session.id = null; + session.sessionId = null; const { username, password, code } = req.body; @@ -60,8 +61,7 @@ export default fastifyPlugin( totp: true, }); - session.user = user; - await session.save(); + await saveSession(session, user as User, false); delete (user as any).password; diff --git a/src/server/routes/api/auth/register.ts b/src/server/routes/api/auth/register.ts index a6dc0202..2be2c749 100755 --- a/src/server/routes/api/auth/register.ts +++ b/src/server/routes/api/auth/register.ts @@ -1,8 +1,8 @@ import { config } from '@/lib/config'; import { createToken, hashPassword } from '@/lib/crypto'; import { prisma } from '@/lib/db'; -import { userSelect } from '@/lib/db/models/user'; -import { getSession } from '@/server/session'; +import { User, userSelect } from '@/lib/db/models/user'; +import { getSession, saveSession } from '@/server/session'; import fastifyPlugin from 'fastify-plugin'; import { ApiLoginResponse } from './login'; @@ -76,8 +76,7 @@ export default fastifyPlugin( }, }); - session.user = user; - await session.save(); + await saveSession(session, user); delete (user as any).password; diff --git a/src/server/routes/api/auth/webauthn.ts b/src/server/routes/api/auth/webauthn.ts index 95926576..45cc33ba 100755 --- a/src/server/routes/api/auth/webauthn.ts +++ b/src/server/routes/api/auth/webauthn.ts @@ -1,7 +1,7 @@ import { config } from '@/lib/config'; import { prisma } from '@/lib/db'; import { User, userSelect } from '@/lib/db/models/user'; -import { getSession } from '@/server/session'; +import { getSession, saveSession } from '@/server/session'; import { AuthenticationResponseJSON } from '@github/webauthn-json/dist/types/browser-ponyfill'; import fastifyPlugin from 'fastify-plugin'; @@ -47,8 +47,7 @@ export default fastifyPlugin( }); if (!user) return res.badRequest('Invalid passkey'); - session.user = user; - await session.save(); + await saveSession(session, user); delete (user as any).password; diff --git a/src/server/routes/api/user/index.ts b/src/server/routes/api/user/index.ts index 627e3c1f..e22c89c1 100755 --- a/src/server/routes/api/user/index.ts +++ b/src/server/routes/api/user/index.ts @@ -2,7 +2,7 @@ import { hashPassword } from '@/lib/crypto'; import { prisma } from '@/lib/db'; import { User, userSelect } from '@/lib/db/models/user'; import { userMiddleware } from '@/server/middleware/user'; -import { getSession } from '@/server/session'; +import { getSession, saveSession } from '@/server/session'; import fastifyPlugin from 'fastify-plugin'; export type ApiUserResponse = { @@ -75,12 +75,13 @@ export default fastifyPlugin( }, select: { ...userSelect, + password: true, + token: true, }, }); const session = await getSession(req, res); - session.user = user; - await session.save(); + await saveSession(session, user); delete (user as any).password; diff --git a/src/server/routes/api/user/token.ts b/src/server/routes/api/user/token.ts index 2efde99d..9c9b2e6d 100755 --- a/src/server/routes/api/user/token.ts +++ b/src/server/routes/api/user/token.ts @@ -3,7 +3,6 @@ import { createToken, encryptToken } from '@/lib/crypto'; import { prisma } from '@/lib/db'; import { User, userSelect } from '@/lib/db/models/user'; import { userMiddleware } from '@/server/middleware/user'; -import { getSession } from '@/server/session'; import fastifyPlugin from 'fastify-plugin'; export type ApiUserTokenResponse = { @@ -32,8 +31,6 @@ export default fastifyPlugin( }); server.patch(PATH, { preHandler: [userMiddleware] }, async (req, res) => { - const session = await getSession(req, res); - const user = await prisma.user.update({ where: { id: req.user.id, @@ -47,8 +44,6 @@ export default fastifyPlugin( }, }); - session.user!.token = user.token; - delete (user as any).password; return res.send({ diff --git a/src/server/session.ts b/src/server/session.ts index 28abc1a3..3d6f1a6f 100644 --- a/src/server/session.ts +++ b/src/server/session.ts @@ -1,5 +1,7 @@ import { config } from '@/lib/config'; +import { prisma } from '@/lib/db'; import { User } from '@/lib/db/models/user'; +import { randomCharacters } from '@/lib/random'; import { FastifyReply, FastifyRequest } from 'fastify'; import { IncomingMessage, ServerResponse } from 'http'; import { getIronSession } from 'iron-session'; @@ -12,12 +14,17 @@ const cookieOptions = { sameSite: 'lax', }; +export type ZiplineSession = { + id: string | null; + sessionId: string | null; +}; + export async function getSession( req: FastifyRequest | IncomingMessage, reply: FastifyReply | ServerResponse, ) { if (!(req as any).raw || !(req as any).raw) { - const session = await getIronSession<{ user: User | null }>( + const session = await getIronSession( req as IncomingMessage, reply as ServerResponse, { @@ -30,7 +37,7 @@ export async function getSession( return session; } - const session = await getIronSession<{ user: User | null }>( + const session = await getIronSession( (req as FastifyRequest).raw, (reply as FastifyReply).raw, { @@ -42,3 +49,25 @@ export async function getSession( return session; } + +export async function saveSession( + session: Awaited>, + user: User, + overwriteSessions = true, +) { + session.id = user.id; + + const sessionId = randomCharacters(32); + session.sessionId = sessionId; + + await prisma.user.update({ + where: { + id: user.id, + }, + data: { + sessions: overwriteSessions ? { set: [sessionId] } : { push: sessionId }, + }, + }); + + await session.save(); +}