fix: sessions & authentik -> oidc & #603

This commit is contained in:
diced 2024-09-04 22:55:15 -07:00
parent 708b130002
commit 67a7d44198
No known key found for this signature in database
GPG key ID: 370BD1BA142842D1
20 changed files with 119 additions and 107 deletions

View file

@ -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

View file

@ -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
}
}

View file

@ -24,14 +24,14 @@ const icons = {
DISCORD: <IconBrandDiscordFilled size='1rem' />,
GITHUB: <IconBrandGithubFilled size='1rem' />,
GOOGLE: <IconBrandGoogleFilled size='1rem' stroke={4} />,
AUTHENTIK: <IconCircleKeyFilled size='1rem' />,
OIDC: <IconCircleKeyFilled size='1rem' />,
};
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 (
<Paper withBorder p='sm'>
@ -103,7 +103,7 @@ export default function SettingsOAuth() {
{config.oauthEnabled.discord && <OAuthButton provider='DISCORD' linked={!!discordLinked} />}
{config.oauthEnabled.github && <OAuthButton provider='GITHUB' linked={!!githubLinked} />}
{config.oauthEnabled.google && <OAuthButton provider='GOOGLE' linked={!!googleLinked} />}
{config.oauthEnabled.authentik && <OAuthButton provider='AUTHENTIK' linked={!!authentikLinked} />}
{config.oauthEnabled.oidc && <OAuthButton provider='OIDC' linked={!!oidcLinked} />}
</Group>
</Paper>
);

View file

@ -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'),

View file

@ -242,7 +242,7 @@ export const schema = z.object({
clientSecret: z.undefined(),
}),
),
authentik: z
oidc: z
.object({
clientId: z.string(),
clientSecret: z.string(),

View file

@ -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<typeof userViewSchema>;

View file

@ -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;

View file

@ -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,
};
}

View file

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

View file

@ -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>user);
logger.info('linked oauth account', {
provider,
@ -153,8 +147,7 @@ export const withOAuth =
},
});
session.user = user;
await session.save();
await saveSession(session, <User>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, <User>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, <User>nuser);
logger.info('created user with oauth', {
provider,

View file

@ -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;

View file

@ -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));

View file

@ -332,8 +332,8 @@ export default function Login({ config }: InferGetServerSidePropsType<typeof get
leftSection={<IconBrandGoogleFilled stroke={4} />}
/>
)}
{config.oauthEnabled.authentik && (
<ExternalAuthButton provider='Authentik' alpha={0.2} leftSection={<IconCircleKeyFilled />} />
{config.oauthEnabled.oidc && (
<ExternalAuthButton provider='OIDC' alpha={0.2} leftSection={<IconCircleKeyFilled />} />
)}
</Stack>
</Card>

View file

@ -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,
});

View file

@ -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;

View file

@ -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>user);
delete (user as any).password;

View file

@ -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>user);
delete (user as any).password;

View file

@ -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>user);
delete (user as any).password;

View file

@ -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({

View file

@ -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<IncomingMessage>,
) {
if (!(req as any).raw || !(req as any).raw) {
const session = await getIronSession<{ user: User | null }>(
const session = await getIronSession<ZiplineSession>(
req as IncomingMessage,
reply as ServerResponse<IncomingMessage>,
{
@ -30,7 +37,7 @@ export async function getSession(
return session;
}
const session = await getIronSession<{ user: User | null }>(
const session = await getIronSession<ZiplineSession>(
(req as FastifyRequest).raw,
(reply as FastifyReply).raw,
{
@ -42,3 +49,25 @@ export async function getSession(
return session;
}
export async function saveSession(
session: Awaited<ReturnType<typeof getSession>>,
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();
}