diff --git a/package.json b/package.json index 13d58907..b157a193 100755 --- a/package.json +++ b/package.json @@ -61,6 +61,7 @@ "cookie": "^1.1.1", "cross-env": "^10.1.0", "dayjs": "^1.11.19", + "detect-browser": "^5.3.0", "dotenv": "^17.2.3", "fast-glob": "^3.3.3", "fastify": "^5.6.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6cd508bb..e3271c7f 100755 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -128,6 +128,9 @@ importers: dayjs: specifier: ^1.11.19 version: 1.11.19 + detect-browser: + specifier: ^5.3.0 + version: 5.3.0 dotenv: specifier: ^17.2.3 version: 17.2.3 @@ -2718,6 +2721,9 @@ packages: destr@2.0.5: resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==} + detect-browser@5.3.0: + resolution: {integrity: sha512-53rsFbGdwMwlF7qvCt0ypLM5V5/Mbl0szB7GPN8y9NCcbknYOeVVXdrXEq+90IwAfrrzt6Hd+u2E2ntakICU8w==} + detect-libc@1.0.3: resolution: {integrity: sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==} engines: {node: '>=0.10'} @@ -8132,6 +8138,8 @@ snapshots: destr@2.0.5: {} + detect-browser@5.3.0: {} + detect-libc@1.0.3: optional: true diff --git a/prisma/migrations/20260214054654_revamp_sessions/migration.sql b/prisma/migrations/20260214054654_revamp_sessions/migration.sql new file mode 100644 index 00000000..0fedede1 --- /dev/null +++ b/prisma/migrations/20260214054654_revamp_sessions/migration.sql @@ -0,0 +1,23 @@ +/* + Warnings: + + - You are about to drop the column `sessions` on the `User` table. All the data in the column will be lost. + +*/ +-- AlterTable +ALTER TABLE "public"."User" DROP COLUMN "sessions"; + +-- CreateTable +CREATE TABLE "public"."UserSession" ( + "id" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "ua" TEXT NOT NULL, + "client" TEXT NOT NULL, + "device" TEXT NOT NULL, + "userId" TEXT NOT NULL, + + CONSTRAINT "UserSession_pkey" PRIMARY KEY ("id") +); + +-- AddForeignKey +ALTER TABLE "public"."UserSession" ADD CONSTRAINT "UserSession_userId_fkey" FOREIGN KEY ("userId") REFERENCES "public"."User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 6b19912a..758db2c3 100755 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -163,7 +163,7 @@ model User { totpSecret String? passkeys UserPasskey[] - sessions String[] + sessions UserSession[] quota UserQuota? @@ -191,6 +191,18 @@ model Export { userId String } +model UserSession { + id String @id + createdAt DateTime @default(now()) + + ua String + client String + device String + + User User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade) + userId String +} + model UserQuota { id String @id @default(cuid()) createdAt DateTime @default(now()) diff --git a/src/client/pages/auth/login.tsx b/src/client/pages/auth/login.tsx index 79cf7085..2462f77f 100644 --- a/src/client/pages/auth/login.tsx +++ b/src/client/pages/auth/login.tsx @@ -1,4 +1,5 @@ import ExternalAuthButton from '@/components/pages/login/ExternalAuthButton'; +import { getWebClient } from '@/lib/api/detect'; import { Response } from '@/lib/api/response'; import { fetchApi } from '@/lib/fetchApi'; import useLogin from '@/lib/hooks/useLogin'; @@ -51,6 +52,8 @@ export default function Login() { const isHttps = window.location.protocol === 'https:'; + const webClient = JSON.stringify(getWebClient()); + const { data: config, error: configError, @@ -104,11 +107,18 @@ export default function Login() { const { username, password } = values; - const { data, error } = await fetchApi('/api/auth/login', 'POST', { - username, - password, - code, - }); + const { data, error } = await fetchApi( + '/api/auth/login', + 'POST', + { + username, + password, + code, + }, + { + 'x-zipline-client': webClient, + }, + ); if (error) { if (error.error === 'Invalid username or password') { @@ -154,9 +164,16 @@ export default function Login() { } const res = await startAuthentication({ optionsJSON: options!.options! }); - const { data, error } = await fetchApi('/api/auth/webauthn', 'POST', { - response: res, - }); + const { data, error } = await fetchApi( + '/api/auth/webauthn', + 'POST', + { + response: res, + }, + { + 'x-zipline-client': webClient, + }, + ); if (error) { setPasskeyErrored(true); setPasskeyLoading(false); @@ -225,8 +242,6 @@ export default function Login() { if (!config) return ; - console.log(isHttps, config.returnHttps); - return ( <> {willRedirect && !showLocalLogin && } diff --git a/src/client/pages/auth/register.tsx b/src/client/pages/auth/register.tsx index 002e943f..8f5778ef 100644 --- a/src/client/pages/auth/register.tsx +++ b/src/client/pages/auth/register.tsx @@ -22,6 +22,7 @@ import { useEffect, useState } from 'react'; import { Link, useLocation, useNavigate } from 'react-router-dom'; import useSWR, { mutate } from 'swr'; import GenericError from '../../error/GenericError'; +import { getWebClient } from '@/lib/api/detect'; export function Component() { useTitle('Register'); @@ -99,11 +100,18 @@ export function Component() { return; } - const { data, error } = await fetchApi('/api/auth/register', 'POST', { - username, - password, - code, - }); + const { data, error } = await fetchApi( + '/api/auth/register', + 'POST', + { + username, + password, + code, + }, + { + 'x-zipline-client': JSON.stringify(getWebClient()), + }, + ); if (error) { if (error.error === 'Username is taken') { diff --git a/src/components/pages/settings/parts/SettingsSessions.tsx b/src/components/pages/settings/parts/SettingsSessions.tsx index 86574e0d..cd191f86 100644 --- a/src/components/pages/settings/parts/SettingsSessions.tsx +++ b/src/components/pages/settings/parts/SettingsSessions.tsx @@ -1,15 +1,18 @@ import { Response } from '@/lib/api/response'; import { fetchApi } from '@/lib/fetchApi'; -import { Button, Paper, SimpleGrid, Skeleton, Text, Title } from '@mantine/core'; +import { ActionIcon, Button, Modal, Paper, SimpleGrid, Skeleton, Table, Text, Title } from '@mantine/core'; import { modals } from '@mantine/modals'; import { showNotification } from '@mantine/notifications'; -import { IconLogout } from '@tabler/icons-react'; +import { IconLogout, IconTrashFilled, IconUsers } from '@tabler/icons-react'; +import { useState } from 'react'; import { Link } from 'react-router-dom'; import useSWR from 'swr'; export default function SettingsSessions() { const { data, isLoading, mutate } = useSWR('/api/user/sessions'); + const [open, setOpen] = useState(false); + const handleLogOutOfAllDevices = async () => { modals.openConfirmModal({ title: 'Log out of all devices?', @@ -36,35 +39,107 @@ export default function SettingsSessions() { }); }; + const handleLogOutOfDevice = async (sessionId: string) => { + modals.openConfirmModal({ + title: 'Log out of device?', + children: 'Are you sure you want to log out of this device?', + onConfirm: async () => { + const { error } = await fetchApi('/api/user/sessions', 'DELETE', { + sessionId, + }); + + if (!error) { + showNotification({ + message: 'Logged out of device', + color: 'blue', + icon: , + }); + } + mutate(); + }, + labels: { + cancel: 'Cancel', + confirm: 'Log out', + }, + }); + }; + + const tableRows = data?.other.map((element) => ( + + {element.client} + {element.device} + {new Date(element.createdAt).toLocaleString()} + + handleLogOutOfDevice(element.id)}> + + + + + )); + return ( - - Sessions + <> + setOpen(false)} size='lg'> + + {data?.other?.length ? ( + + + + Client + Device + Logged in at + + + + {tableRows} +
+ ) : ( + + No other sessions found + + )} +
- - - You are currently logged into {isLoading ? '...' : (data?.other?.length ?? '...')} other devices - - - - - - -
+ + + + Sessions + + + + You are currently logged into {isLoading ? '...' : (data?.other?.length ?? '...')} other devices + + + + + + + + + + ); } diff --git a/src/lib/api/detect.ts b/src/lib/api/detect.ts new file mode 100644 index 00000000..f71baa3b --- /dev/null +++ b/src/lib/api/detect.ts @@ -0,0 +1,79 @@ +import { detect } from 'detect-browser'; +import z from 'zod'; + +const ziplineClientSchema = z.object({ + client: z.string(), + device: z.string(), + ua: z.string(), +}); + +export type ZiplineClient = z.infer; + +export const ziplineClientParseSchema = z.string().transform((str, ctx) => { + const parsed = parseZiplineClient(str); + if (!parsed) { + ctx.addIssue({ + code: 'custom', + message: 'Invalid Zipline Client header', + }); + return z.NEVER; + } + return parsed; +}); + +export function getWebClient(): ZiplineClient { + if (typeof window === 'undefined') { + return { + client: 'unknown', + device: 'unknown', + ua: 'unknown', + }; + } + + const ua = navigator.userAgent; + const device = clientFromUA(ua); + + return { + client: 'Zipline Web', + device, + ua, + }; +} + +function parseZiplineClient(header: string | undefined): ZiplineClient | null { + if (!header) return null; + + try { + const parsed = JSON.parse(header); + return ziplineClientSchema.parse(parsed); + } catch { + return null; + } +} + +function clientFromUA(ua: string): string { + const detectedBrowser = detect(ua); + + const browser = detectedBrowser?.name ?? 'unknown'; + const version = detectedBrowser?.version ?? 'unknown'; + + return `${browser} ${version}`; +} + +export function detectClient(headers: Record): ZiplineClient { + const ua = headers['user-agent'] ?? ''; + + const header = headers['x-zipline-client']; + const ziplineClient = typeof header === 'object' ? header : parseZiplineClient(header); + + const detectedBrowser = detect(ua); + + const client = ziplineClient?.client ?? clientFromUA(ua); + const device = ziplineClient?.device ?? detectedBrowser?.os ?? 'Web'; + + return { + client, + device, + ua, + }; +} diff --git a/src/lib/db/models/user.ts b/src/lib/db/models/user.ts index dffda982..d55c9ad3 100755 --- a/src/lib/db/models/user.ts +++ b/src/lib/db/models/user.ts @@ -1,4 +1,4 @@ -import { OAuthProvider, UserPasskey, UserQuota } from '@/prisma/client'; +import { OAuthProvider, UserPasskey, UserQuota, UserSession } from '@/prisma/client'; import { z } from 'zod'; export type User = { @@ -9,7 +9,7 @@ export type User = { role: 'USER' | 'ADMIN' | 'SUPERADMIN'; view: UserViewSettings; - sessions: string[]; + sessions: UserSession[]; oauthProviders: OAuthProvider[]; diff --git a/src/server/middleware/user.ts b/src/server/middleware/user.ts index 06f5ff46..53a87f6b 100755 --- a/src/server/middleware/user.ts +++ b/src/server/middleware/user.ts @@ -81,7 +81,9 @@ export async function userMiddleware(req: FastifyRequest, res: FastifyReply) { const user = await prisma.user.findFirst({ where: { sessions: { - has: session.sessionId, + some: { + id: session.sessionId, + }, }, }, select: userSelect, diff --git a/src/server/plugins/oauth.ts b/src/server/plugins/oauth.ts index afdec8ac..2b4f7e13 100644 --- a/src/server/plugins/oauth.ts +++ b/src/server/plugins/oauth.ts @@ -82,7 +82,9 @@ async function oauthPlugin(fastify: FastifyInstance) { const user = await prisma.user.findFirst({ where: { sessions: { - has: session.sessionId ?? '', + some: { + id: session.sessionId ?? '', + }, }, }, include: { diff --git a/src/server/routes/api/auth/login.ts b/src/server/routes/api/auth/login.ts index 6a4b8b55..4a1f6e35 100755 --- a/src/server/routes/api/auth/login.ts +++ b/src/server/routes/api/auth/login.ts @@ -1,3 +1,4 @@ +import { ziplineClientParseSchema } from '@/lib/api/detect'; import { verifyPassword } from '@/lib/crypto'; import { prisma } from '@/lib/db'; import { User, userSelect } from '@/lib/db/models/user'; @@ -28,6 +29,9 @@ export default typedPlugin( password: zStringTrimmed, code: z.string().min(1).optional(), }), + headers: z.object({ + 'x-zipline-client': ziplineClientParseSchema.optional(), + }), }, ...secondlyRatelimit(2), }, diff --git a/src/server/routes/api/auth/logout.ts b/src/server/routes/api/auth/logout.ts index 97dbd4f5..8cc7d3e9 100755 --- a/src/server/routes/api/auth/logout.ts +++ b/src/server/routes/api/auth/logout.ts @@ -16,14 +16,10 @@ export default typedPlugin( server.get(PATH, { preHandler: [userMiddleware] }, async (req, res) => { const current = await getSession(req, res); - await prisma.user.update({ + await prisma.userSession.deleteMany({ where: { - id: req.user.id, - }, - data: { - sessions: { - set: req.user.sessions.filter((session) => session !== current.sessionId), - }, + id: current.sessionId!, + userId: req.user.id, }, }); diff --git a/src/server/routes/api/auth/register.ts b/src/server/routes/api/auth/register.ts index cd7dbed8..b7621a3b 100755 --- a/src/server/routes/api/auth/register.ts +++ b/src/server/routes/api/auth/register.ts @@ -9,6 +9,7 @@ import typedPlugin from '@/server/typedPlugin'; import z from 'zod'; import { ApiLoginResponse } from './login'; import { zStringTrimmed } from '@/lib/validation'; +import { ziplineClientParseSchema } from '@/lib/api/detect'; export type ApiAuthRegisterResponse = ApiLoginResponse; @@ -26,6 +27,9 @@ export default typedPlugin( password: zStringTrimmed, code: z.string().min(1).optional(), }), + headers: z.object({ + 'x-zipline-client': ziplineClientParseSchema.optional(), + }), }, ...secondlyRatelimit(5), }, diff --git a/src/server/routes/api/auth/webauthn.ts b/src/server/routes/api/auth/webauthn.ts index 4373cf2b..5ee5be62 100755 --- a/src/server/routes/api/auth/webauthn.ts +++ b/src/server/routes/api/auth/webauthn.ts @@ -16,6 +16,7 @@ import { } from '@simplewebauthn/server'; import z from 'zod'; import { PasskeyReg, passkeysEnabledHandler } from '../user/mfa/passkey'; +import { ziplineClientParseSchema } from '@/lib/api/detect'; export type ApiAuthWebauthnResponse = { user: User; @@ -74,6 +75,9 @@ export default typedPlugin( body: z.object({ response: z.custom(), }), + headers: z.object({ + 'x-zipline-client': ziplineClientParseSchema.optional(), + }), }, preHandler: [passkeysEnabledHandler], ...secondlyRatelimit(10), diff --git a/src/server/routes/api/user/sessions.ts b/src/server/routes/api/user/sessions.ts index b55f572f..54904188 100644 --- a/src/server/routes/api/user/sessions.ts +++ b/src/server/routes/api/user/sessions.ts @@ -1,13 +1,14 @@ import { prisma } from '@/lib/db'; import { log } from '@/lib/logger'; +import type { UserSession } from '@/prisma/client'; import { userMiddleware } from '@/server/middleware/user'; import { getSession } from '@/server/session'; import typedPlugin from '@/server/typedPlugin'; import z from 'zod'; export type ApiUserSessionsResponse = { - current: string; - other: string[]; + current: UserSession; + other: UserSession[]; }; const logger = log('api').c('user').c('sessions'); @@ -17,9 +18,13 @@ export default typedPlugin( server.get(PATH, { preHandler: [userMiddleware] }, async (req, res) => { const currentSession = await getSession(req, res); + const currentDbSession = req.user.sessions.find((session) => session.id === currentSession.sessionId); + + if (!currentDbSession) return res.unauthorized('invalid login session'); + return res.send({ - current: currentSession.sessionId, - other: req.user.sessions.filter((session) => session !== currentSession.sessionId), + current: currentDbSession, + other: req.user.sessions.filter((session) => session.id !== currentSession.sessionId), }); }); @@ -28,7 +33,7 @@ export default typedPlugin( { schema: { body: z.object({ - sessionId: z.string(), + sessionId: z.string().optional(), all: z.boolean().optional(), }), }, @@ -38,15 +43,22 @@ export default typedPlugin( const currentSession = await getSession(req, res); if (req.body.all) { - await prisma.user.update({ + const user = await prisma.user.update({ where: { id: req.user.id, }, data: { sessions: { - set: [currentSession.sessionId!], + deleteMany: { + NOT: { + id: currentSession.sessionId!, + }, + }, }, }, + include: { + sessions: true, + }, }); logger.info('user logged out all logged in sessions', { @@ -54,27 +66,30 @@ export default typedPlugin( }); return res.send({ - current: currentSession.sessionId, + current: user.sessions.find((session) => session.id === currentSession.sessionId)!, other: [], }); } if (req.body.sessionId === currentSession.sessionId) - return res.badRequest('Cannot delete current session'); - if (!req.user.sessions.includes(req.body.sessionId)) + return res.badRequest('Cannot delete current session, use log out instead.'); + if (!req.user.sessions.find((session) => session.id === req.body.sessionId)) return res.badRequest('Session not found in logged in sessions'); - const sessionsWithout = req.user.sessions.filter((session) => session !== req.body.sessionId); - - await prisma.user.update({ + const user = await prisma.user.update({ where: { id: req.user.id, }, data: { sessions: { - set: sessionsWithout, + delete: { + id: req.body.sessionId, + }, }, }, + include: { + sessions: true, + }, }); logger.info('user logged out of session', { @@ -83,8 +98,8 @@ export default typedPlugin( }); return res.send({ - current: currentSession.sessionId, - other: sessionsWithout, + current: user.sessions.find((session) => session.id === currentSession.sessionId)!, + other: user.sessions.filter((session) => session.id !== currentSession.sessionId), }); }, ); diff --git a/src/server/session.ts b/src/server/session.ts index 5ff1022f..40988c6a 100644 --- a/src/server/session.ts +++ b/src/server/session.ts @@ -1,3 +1,4 @@ +import { detectClient, ZiplineClient } from '@/lib/api/detect'; import { config } from '@/lib/config'; import { prisma } from '@/lib/db'; import { randomCharacters } from '@/lib/random'; @@ -18,6 +19,7 @@ const cookieOptions: NonNullable = { export type ZiplineSession = { id: string | null; sessionId: string | null; + client: ZiplineClient; }; export async function getSession( @@ -50,6 +52,8 @@ export async function getSession( }, ); + session.client = detectClient(>req.headers); + return session; } @@ -66,12 +70,37 @@ export async function saveSession( const sessionId = randomCharacters(32); session.sessionId = sessionId; - await prisma.user.update({ - where: { id: user.id }, - data: { - sessions: overwriteSessions ? { set: [sessionId] } : { push: sessionId }, - }, - }); + if (overwriteSessions) { + await prisma.user.update({ + where: { id: user.id }, + data: { + sessions: { + set: [ + { + id: sessionId, + client: session.client.client, + device: session.client.device, + ua: session.client.ua, + }, + ], + }, + }, + }); + } else { + await prisma.user.update({ + where: { id: user.id }, + data: { + sessions: { + create: { + id: sessionId, + client: session.client.client, + device: session.client.device, + ua: session.client.ua, + }, + }, + }, + }); + } } await session.save();