feat: sessions

This commit is contained in:
diced
2024-09-01 22:35:16 -07:00
parent db28b06791
commit 1d5e546851
17 changed files with 124 additions and 94 deletions

View File

@@ -56,6 +56,7 @@
"fflate": "^0.8.2",
"fluent-ffmpeg": "^2.1.3",
"highlight.js": "^11.9.0",
"iron-session": "^8.0.3",
"isomorphic-dompurify": "^1.11.0",
"katex": "^0.16.9",
"mantine-datatable": "^7.1.7",

22
pnpm-lock.yaml generated
View File

@@ -116,6 +116,9 @@ importers:
highlight.js:
specifier: ^11.9.0
version: 11.9.0
iron-session:
specifier: ^8.0.3
version: 8.0.3
isomorphic-dompurify:
specifier: ^1.11.0
version: 1.11.0
@@ -3100,6 +3103,12 @@ packages:
resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==}
engines: {node: '>= 0.10'}
iron-session@8.0.3:
resolution: {integrity: sha512-WtDX0griBliMoR6hGoU3SlefW+VSbfHrIVqURQ0Nbg/Pd+nj7VDsKV+sx0FHjyUCaO02YoYV5v+kW0PqvFJISQ==}
iron-webcrypto@1.2.1:
resolution: {integrity: sha512-feOM6FaSr6rEABp/eDfVseKyTMDt+KGpeB35SkVn9Tyn0CqvVsY3EwI0v5i8nMHyJnzCIQf7nsy3p41TPkJZhg==}
is-arguments@1.1.1:
resolution: {integrity: sha512-8Q7EARjzEnKpt/PCD7e1cgUS0a6X8u5tdSiMqXhojOdoV9TsMsiO+9VLC5vAmO8N7/GmXn7yjR8qnA6bVAEzfA==}
engines: {node: '>= 0.4'}
@@ -5120,6 +5129,9 @@ packages:
unbox-primitive@1.0.2:
resolution: {integrity: sha512-61pPlCD9h51VoreyJ0BReideM3MDKMKnh6+V9L08331ipq6Q8OFXZYiqP6n/tbHx4s5I9uRhcye6BrbkizkBDw==}
uncrypto@0.1.3:
resolution: {integrity: sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==}
undici-types@5.26.5:
resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==}
@@ -9235,6 +9247,14 @@ snapshots:
ipaddr.js@1.9.1: {}
iron-session@8.0.3:
dependencies:
cookie: 0.6.0
iron-webcrypto: 1.2.1
uncrypto: 0.1.3
iron-webcrypto@1.2.1: {}
is-arguments@1.1.1:
dependencies:
call-bind: 1.0.5
@@ -11510,6 +11530,8 @@ snapshots:
has-symbols: 1.0.3
which-boxed-primitive: 1.0.2
uncrypto@0.1.3: {}
undici-types@5.26.5: {}
unified@10.1.2:

View File

@@ -138,7 +138,7 @@ export default function Layout({ children, config }: { children: React.ReactNode
const router = useRouter();
const modals = useModals();
const clipboard = useClipboard();
const [setUser, setToken] = useUserStore((s) => [s.setUser, s.setToken]);
const [setUser] = useUserStore((s) => [s.setUser]);
const { user, mutate } = useLogin();
const { avatar } = useAvatar();
@@ -163,7 +163,7 @@ export default function Layout({ children, config }: { children: React.ReactNode
icon: <IconClipboardCopy size='1rem' />,
});
} else {
clipboard.copy(data?.token);
clipboard.copy(data?.token ?? '');
showNotification({
title: 'Copied',
message: 'Your token has been copied to your clipboard.',
@@ -196,7 +196,6 @@ export default function Layout({ children, config }: { children: React.ReactNode
icon: <IconRefreshDot size='1rem' />,
});
} else {
setToken(data?.token);
setUser(data?.user);
mutate(data as Response['/api/user']);

View File

@@ -7,15 +7,15 @@ import { isAdministrator } from '../role';
export default function useLogin(administratorOnly: boolean = false) {
const router = useRouter();
const { data, error, isLoading, mutate } = useSWR<Response['/api/user']>('/api/user');
const { data, error, isLoading, mutate } = useSWR<Response['/api/user']>('/api/user', {
fallbackData: { user: undefined },
});
const [user, setUser] = useUserStore((state) => [state.user, state.setUser]);
const [token, setToken] = useUserStore((state) => [state.token, state.setToken]);
useEffect(() => {
if (data?.user && data?.token) {
if (data?.user) {
setUser(data.user);
setToken(data.token);
} else if (error) {
router.push('/auth/login');
}
@@ -27,5 +27,5 @@ export default function useLogin(administratorOnly: boolean = false) {
}
}, [user]);
return { user, token, loading: isLoading || !user, mutate };
return { user, loading: isLoading || !user, mutate };
}

View File

@@ -1,21 +0,0 @@
import { config } from './config';
import { serializeCookie } from './cookie';
import { encryptToken } from './crypto';
import { User } from './db/models/user';
import { NextApiRes } from './response';
export function loginToken(res: NextApiRes, user: User) {
const token = encryptToken(user.token!, config.core.secret);
const cookie = serializeCookie('zipline_token', token, {
// week
maxAge: 60 * 60 * 24 * 7,
expires: new Date(Date.now() + 60 * 60 * 24 * 7 * 1000),
path: '/',
sameSite: 'lax',
});
res.setHeader('Set-Cookie', cookie);
return token;
}

View File

@@ -5,9 +5,9 @@ import { parseUserToken } from '../middleware/ziplineAuth';
import { findProvider } from './providerUtil';
import { createToken } from '../crypto';
import { config } from '../config';
import { loginToken } from '../login';
import { User } from '../db/models/user';
import Logger, { log } from '../logger';
import { getSession } from '@/server/session';
export interface OAuthQuery {
state?: string;
@@ -39,6 +39,8 @@ export const withOAuth =
const response = await oauthProfile(req.query as OAuthQuery, logger);
const session = await getSession(req, res);
if (response.error) {
return res.serverError(response.error, {
oauth: response.error_code,
@@ -120,7 +122,8 @@ export const withOAuth =
},
});
loginToken(res, user);
session.user = user;
await session.save();
logger.info('linked oauth account', {
provider,
@@ -150,7 +153,8 @@ export const withOAuth =
},
});
loginToken(res, user);
session.user = user;
await session.save();
return res.redirect('/dashboard');
} else if (existingOauth) {
@@ -169,7 +173,8 @@ export const withOAuth =
},
});
loginToken(res, login.user! as User);
session.user = login.user! as User;
await session.save();
logger.info('logged in with oauth', {
provider,
@@ -201,7 +206,8 @@ export const withOAuth =
},
});
loginToken(res, nuser as User);
session.user = nuser as User;
await session.save();
logger.info('created user with oauth', {
provider,

View File

@@ -3,14 +3,10 @@ import type { User } from '../db/models/user';
type UserStore = {
user: User | null;
token: string | null;
setUser: (user?: User | null) => void;
setToken: (token?: string | null) => void;
};
export const useUserStore = create<UserStore>()((set) => ({
user: null,
token: null,
setUser: (user) => set({ user }),
setToken: (token) => set({ token }),
}));

View File

@@ -6,7 +6,7 @@ import { mutate } from 'swr';
export default function Login() {
const router = useRouter();
const [setUser, setToken] = useUserStore((state) => [state.setUser, state.setToken]);
const [setUser] = useUserStore((state) => [state.setUser]);
useEffect(() => {
(async () => {
@@ -16,7 +16,6 @@ export default function Login() {
if (res.ok) {
setUser(null);
setToken(null);
mutate('/api/user', null);
await router.push('/auth/login');
}

View File

@@ -1,21 +0,0 @@
import { config } from '@/lib/config';
import { serializeCookie } from '@/lib/cookie';
import { encryptToken } from '@/lib/crypto';
import { User } from '@/lib/db/models/user';
import { FastifyReply } from 'fastify';
export function loginToken(res: FastifyReply, user: User) {
const token = encryptToken(user.token!, config.core.secret);
const cookie = serializeCookie('zipline_token', token, {
// week
maxAge: 60 * 60 * 24 * 7,
expires: new Date(Date.now() + 60 * 60 * 24 * 7 * 1000),
path: '/',
sameSite: 'lax',
});
res.header('Set-Cookie', cookie);
return token;
}

View File

@@ -4,6 +4,7 @@ import { prisma } from '@/lib/db';
import { User, userSelect } from '@/lib/db/models/user';
import { FastifyReply } from 'fastify';
import { FastifyRequest } from 'fastify/types/request';
import { getSession } from '../session';
declare module 'fastify' {
export interface FastifyRequest {
@@ -38,25 +39,17 @@ export function parseUserToken(
}
export async function userMiddleware(req: FastifyRequest, res: FastifyReply) {
let rawToken: string | undefined;
const session = await getSession(req, res);
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);
}
if (!session.user) return res.unauthorized('not logged in');
const user = await prisma.user.findFirst({
where: {
token,
password: session.user.password,
},
select: userSelect,
});
if (!user) return res.unauthorized('invalid token');
if (!user) return res.unauthorized('invalid login session');
req.user = user;
}

View File

@@ -2,12 +2,11 @@ import { verifyPassword } from '@/lib/crypto';
import { prisma } from '@/lib/db';
import { User, userSelect } from '@/lib/db/models/user';
import { verifyTotpCode } from '@/lib/totp';
import { loginToken } from '@/server/loginToken';
import { getSession } from '@/server/session';
import fastifyPlugin from 'fastify-plugin';
export type ApiLoginResponse = {
user?: User;
token?: string;
totp?: true;
};
@@ -26,6 +25,10 @@ export default fastifyPlugin(
url: PATH,
method: ['POST'],
handler: async (req, res) => {
const session = await getSession(req, res);
session.user = null;
const { username, password, code } = req.body;
if (!username) return res.badRequest('Username is required');
@@ -57,13 +60,12 @@ export default fastifyPlugin(
totp: true,
});
const token = loginToken(res, user);
session.user = user;
await session.save();
delete (user as any).token;
delete (user as any).password;
return res.send({
token,
user,
});
},

View File

@@ -1,4 +1,5 @@
import { userMiddleware } from '@/server/middleware/user';
import { getSession } from '@/server/session';
import fastifyPlugin from 'fastify-plugin';
export type ApiLogoutResponse = {
@@ -14,8 +15,9 @@ export default fastifyPlugin(
url: PATH,
method: ['GET'],
preHandler: [userMiddleware],
handler: async (_, res) => {
res.header('Set-Cookie', 'zipline_token=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT');
handler: async (req, res) => {
const session = await getSession(req, res);
session.destroy();
return res.send({ loggedOut: true });
},

View File

@@ -2,9 +2,9 @@ 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 fastifyPlugin from 'fastify-plugin';
import { ApiLoginResponse } from './login';
import { loginToken } from '@/server/loginToken';
export type ApiAuthRegisterResponse = ApiLoginResponse;
@@ -22,6 +22,8 @@ export default fastifyPlugin(
url: PATH,
method: ['POST'],
handler: async (req, res) => {
const session = await getSession(req, res);
const { username, password, code } = req.body;
if (code && !config.invites.enabled) return res.badRequest("Invites aren't enabled");
@@ -74,13 +76,12 @@ export default fastifyPlugin(
},
});
const token = loginToken(res, user);
session.user = user;
await session.save();
delete (user as any).token;
delete (user as any).password;
return res.send({
token,
user,
});
},

View File

@@ -1,13 +1,12 @@
import { config } from '@/lib/config';
import { prisma } from '@/lib/db';
import { User, userSelect } from '@/lib/db/models/user';
import { loginToken } from '@/server/loginToken';
import { getSession } from '@/server/session';
import { AuthenticationResponseJSON } from '@github/webauthn-json/dist/types/browser-ponyfill';
import fastifyPlugin from 'fastify-plugin';
export type ApiAuthWebauthnResponse = {
user: User;
token: string;
};
type Body = {
@@ -23,6 +22,7 @@ export default fastifyPlugin(
url: PATH,
method: ['POST'],
handler: async (req, res) => {
const session = await getSession(req, res);
if (!config.mfa.passkeys) return res.badRequest('Passkeys are not enabled');
const { auth } = req.body;
@@ -47,9 +47,9 @@ export default fastifyPlugin(
});
if (!user) return res.badRequest('Invalid passkey');
const token = loginToken(res, user);
session.user = user;
await session.save();
delete (user as any).token;
delete (user as any).password;
await prisma.userPasskey.updateMany({
@@ -65,7 +65,6 @@ export default fastifyPlugin(
});
return res.send({
token,
user,
});
},

View File

@@ -2,11 +2,11 @@ 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 fastifyPlugin from 'fastify-plugin';
export type ApiUserResponse = {
user?: User;
token?: string;
};
type Body = {
@@ -78,6 +78,12 @@ export default fastifyPlugin(
},
});
const session = await getSession(req, res);
session.user = user;
await session.save();
delete (user as any).password;
return res.send({ user, token: req.cookies.zipline_token });
});

View File

@@ -2,8 +2,8 @@ import { config } from '@/lib/config';
import { createToken, encryptToken } from '@/lib/crypto';
import { prisma } from '@/lib/db';
import { User, userSelect } from '@/lib/db/models/user';
import { loginToken } from '@/server/loginToken';
import { userMiddleware } from '@/server/middleware/user';
import { getSession } from '@/server/session';
import fastifyPlugin from 'fastify-plugin';
export type ApiUserTokenResponse = {
@@ -32,6 +32,8 @@ 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,
@@ -45,13 +47,13 @@ export default fastifyPlugin(
},
});
const token = loginToken(res, user);
session.user!.token = user.token;
delete (user as any).token;
delete (user as any).password;
return res.send({
user,
token,
token: encryptToken(user.token, config.core.secret),
});
});

44
src/server/session.ts Normal file
View File

@@ -0,0 +1,44 @@
import { config } from '@/lib/config';
import { User } from '@/lib/db/models/user';
import { FastifyReply, FastifyRequest } from 'fastify';
import { IncomingMessage, ServerResponse } from 'http';
import { getIronSession } from 'iron-session';
const cookieOptions = {
// week
maxAge: 60 * 60 * 24 * 7,
expires: new Date(Date.now() + 60 * 60 * 24 * 7 * 1000),
path: '/',
sameSite: 'lax',
};
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 }>(
req as IncomingMessage,
reply as ServerResponse<IncomingMessage>,
{
password: config.core.secret,
cookieName: 'zipline_session',
cookieOptions,
},
);
return session;
}
const session = await getIronSession<{ user: User | null }>(
(req as FastifyRequest).raw,
(reply as FastifyReply).raw,
{
password: config.core.secret,
cookieName: 'zipline_session',
cookieOptions,
},
);
return session;
}