diff --git a/apps/remix/app/components/dialogs/session-logout-all-dialog.tsx b/apps/remix/app/components/dialogs/session-logout-all-dialog.tsx new file mode 100644 index 000000000..c70fb062e --- /dev/null +++ b/apps/remix/app/components/dialogs/session-logout-all-dialog.tsx @@ -0,0 +1,94 @@ +import { useState } from 'react'; + +import { Trans, useLingui } from '@lingui/react/macro'; + +import { authClient } from '@documenso/auth/client'; +import { Button } from '@documenso/ui/primitives/button'; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@documenso/ui/primitives/dialog'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +type SessionLogoutAllDialogProps = { + onSuccess?: () => Promise; + disabled?: boolean; +}; + +export const SessionLogoutAllDialog = ({ onSuccess, disabled }: SessionLogoutAllDialogProps) => { + const { t } = useLingui(); + const { toast } = useToast(); + + const [isOpen, setIsOpen] = useState(false); + const [isLoading, setIsLoading] = useState(false); + + const handleSignOutAllSessions = async () => { + setIsLoading(true); + + try { + await authClient.signOutAllSessions(); + + if (onSuccess) { + await onSuccess(); + } + + toast({ + title: t`Sessions have been revoked`, + }); + + setIsOpen(false); + } catch (error) { + console.error(error); + + toast({ + title: t`Error`, + description: t`Failed to sign out all sessions`, + variant: 'destructive', + }); + } + + setIsLoading(false); + }; + + return ( + (isLoading ? undefined : setIsOpen(value))}> + + + + + + + + Revoke all sessions + + + + This will sign you out of all other devices. You will need to sign in again on those + devices to continue using your account. + + + + + + + + + + + + + + ); +}; diff --git a/apps/remix/app/routes/_authenticated+/settings+/security._index.tsx b/apps/remix/app/routes/_authenticated+/settings+/security._index.tsx index 4d86a13be..eacaaf4fc 100644 --- a/apps/remix/app/routes/_authenticated+/settings+/security._index.tsx +++ b/apps/remix/app/routes/_authenticated+/settings+/security._index.tsx @@ -171,6 +171,27 @@ export default function SettingsSecurity({ loaderData }: Route.ComponentProps) { + + +
+ + Active sessions + + + + View and manage all active sessions for your account. + +
+ + +
); } diff --git a/apps/remix/app/routes/_authenticated+/settings+/security.sessions.tsx b/apps/remix/app/routes/_authenticated+/settings+/security.sessions.tsx new file mode 100644 index 000000000..36762c6c4 --- /dev/null +++ b/apps/remix/app/routes/_authenticated+/settings+/security.sessions.tsx @@ -0,0 +1,192 @@ +import { useMemo, useState } from 'react'; + +import { useLingui } from '@lingui/react/macro'; +import { Trans } from '@lingui/react/macro'; +import { useQuery } from '@tanstack/react-query'; +import { DateTime } from 'luxon'; +import { UAParser } from 'ua-parser-js'; + +import { authClient } from '@documenso/auth/client'; +import { useSession } from '@documenso/lib/client-only/providers/session'; +import { Badge } from '@documenso/ui/primitives/badge'; +import { Button } from '@documenso/ui/primitives/button'; +import type { DataTableColumnDef } from '@documenso/ui/primitives/data-table'; +import { DataTable } from '@documenso/ui/primitives/data-table'; +import { Skeleton } from '@documenso/ui/primitives/skeleton'; +import { TableCell } from '@documenso/ui/primitives/table'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +import { SessionLogoutAllDialog } from '~/components/dialogs/session-logout-all-dialog'; +import { SettingsHeader } from '~/components/general/settings-header'; +import { appMetaTags } from '~/utils/meta'; + +export function meta() { + return appMetaTags('Active Sessions'); +} + +const parser = new UAParser(); + +export default function SettingsSecuritySessions() { + const { t } = useLingui(); + + const { data, isLoading, isLoadingError, refetch } = useQuery({ + queryKey: ['active-sessions'], + queryFn: async () => await authClient.getSessions(), + }); + + const { session } = useSession(); + + const results = data?.sessions ?? []; + + const columns = useMemo(() => { + return [ + { + header: t`Device`, + accessorKey: 'userAgent', + cell: ({ row }) => { + const userAgent = row.original.userAgent || ''; + parser.setUA(userAgent); + + const result = parser.getResult(); + const browser = result.browser.name || t`Unknown`; + const os = result.os.name || t`Unknown`; + const isCurrentSession = row.original.id === session?.id; + + return ( +
+ + {browser} ({os}) + + {isCurrentSession && ( + + Current + + )} +
+ ); + }, + }, + { + header: t`IP Address`, + accessorKey: 'ipAddress', + cell: ({ row }) => row.original.ipAddress || t`Unknown`, + }, + { + header: t`Last Active`, + accessorKey: 'updatedAt', + cell: ({ row }) => DateTime.fromJSDate(row.original.updatedAt).toRelative(), + }, + { + header: t`Created`, + accessorKey: 'createdAt', + cell: ({ row }) => DateTime.fromJSDate(row.original.createdAt).toRelative(), + }, + { + id: 'actions', + cell: ({ row }) => ( + + ), + }, + ] satisfies DataTableColumnDef<(typeof results)[number]>[]; + }, []); + + return ( +
+ + + + +
+ + + + + + + + + + + + + + + + + + ), + }} + /> +
+
+ ); +} + +type SessionRevokeButtonProps = { + sessionId: string; + isCurrentSession: boolean; + onSuccess: () => Promise; +}; + +const SessionRevokeButton = ({ + sessionId, + isCurrentSession, + onSuccess, +}: SessionRevokeButtonProps) => { + const { toast } = useToast(); + const { t } = useLingui(); + + const [isLoading, setIsLoading] = useState(false); + + const handleRevoke = async () => { + setIsLoading(true); + try { + await authClient.signOutSession({ + sessionId, + redirectPath: isCurrentSession ? '/signin' : undefined, + }); + + if (!isCurrentSession) { + await onSuccess(); + } + + toast({ + title: t`Session revoked`, + }); + } catch (error) { + console.error(error); + + toast({ + title: t`Error`, + description: t`Failed to revoke session`, + variant: 'destructive', + }); + } + + setIsLoading(false); + }; + + return ( + + ); +}; diff --git a/packages/app-tests/e2e/user/auth-sessions.spec.ts b/packages/app-tests/e2e/user/auth-sessions.spec.ts new file mode 100644 index 000000000..4c6a22cbc --- /dev/null +++ b/packages/app-tests/e2e/user/auth-sessions.spec.ts @@ -0,0 +1,82 @@ +import { type Page, expect, test } from '@playwright/test'; + +import { seedUser } from '@documenso/prisma/seed/users'; + +import { apiSignin } from '../fixtures/authentication'; +import { expectTextToBeVisible } from '../fixtures/generic'; + +test('[USER] revoke sessions', async ({ page }: { page: Page }) => { + const { user, team } = await seedUser(); + + await apiSignin({ + page, + email: user.email, + password: 'password', + redirectPath: '/settings/security/sessions', + }); + + // Expect 2 rows length (header + 1) + await expect(page.getByRole('row')).toHaveCount(2); + + // Clear cookies + await page.context().clearCookies(); + + await apiSignin({ + page, + email: user.email, + password: 'password', + redirectPath: '/settings/security/sessions', + }); + + await page.context().clearCookies(); + + await apiSignin({ + page, + email: user.email, + password: 'password', + redirectPath: '/settings/security/sessions', + }); + + // Expect 4 (3 sessions + 1 header) rows length + await expect(page.getByRole('row')).toHaveCount(4); + + // Revoke all sessions + await page.getByRole('button', { name: 'Revoke all sessions' }).click(); + await page.getByRole('button', { name: 'Revoke all sessions' }).click(); + + await expectTextToBeVisible(page, 'Sessions have been revoked'); + + // Expect (1 sessions + 1 header) rows length + await expect(page.getByRole('row')).toHaveCount(2); + + await page.context().clearCookies(); + + await apiSignin({ + page, + email: user.email, + password: 'password', + redirectPath: '/settings/security/sessions', + }); + + // Find table row which does not have text 'Current' and click the button called Revoke within the row. + await page + .getByRole('row') + .filter({ hasNotText: 'Current' }) + .nth(1) + .getByRole('button', { name: 'Revoke' }) + .click(); + await expectTextToBeVisible(page, 'Session revoked'); + + // Expect (1 sessions + 1 header) rows length + await expect(page.getByRole('row')).toHaveCount(2); + + // Revoke own session. + await page + .getByRole('row') + .filter({ hasText: 'Current' }) + .first() + .getByRole('button', { name: 'Revoke' }) + .click(); + + await expect(page).toHaveURL('/signin'); +}); diff --git a/packages/auth/client/index.ts b/packages/auth/client/index.ts index 4ef4a1d7c..e99573af6 100644 --- a/packages/auth/client/index.ts +++ b/packages/auth/client/index.ts @@ -7,6 +7,7 @@ import { AppError } from '@documenso/lib/errors/app-error'; import type { AuthAppType } from '../server'; import type { SessionValidationResult } from '../server/lib/session/session'; +import type { ActiveSession } from '../server/lib/utils/get-session'; import { handleSignInRedirect } from '../server/lib/utils/redirect'; import type { TDisableTwoFactorRequestSchema, @@ -47,6 +48,26 @@ export class AuthClient { window.location.href = redirectPath ?? this.signOutredirectPath; } + public async signOutAllSessions() { + await this.client['signout-all'].$post(); + } + + public async signOutSession({ + sessionId, + redirectPath, + }: { + sessionId: string; + redirectPath?: string; + }) { + await this.client['signout-session'].$post({ + json: { sessionId }, + }); + + if (redirectPath) { + window.location.href = redirectPath; + } + } + public async getSession() { const response = await this.client['session-json'].$get(); @@ -57,6 +78,16 @@ export class AuthClient { return superjson.deserialize(result); } + public async getSessions() { + const response = await this.client['sessions'].$get(); + + await this.handleError(response); + + const result = await response.json(); + + return superjson.deserialize<{ sessions: ActiveSession[] }>(result); + } + private async handleError(response: ClientResponse): Promise { if (!response.ok) { const error = await response.json(); diff --git a/packages/auth/index.ts b/packages/auth/index.ts index 98eb92d46..54a148c1f 100644 --- a/packages/auth/index.ts +++ b/packages/auth/index.ts @@ -1,2 +1 @@ -export * from './server/lib/errors/errors'; export * from './server/lib/errors/error-codes'; diff --git a/packages/auth/server/lib/session/session.ts b/packages/auth/server/lib/session/session.ts index 15e1cc04b..c667b7cd8 100644 --- a/packages/auth/server/lib/session/session.ts +++ b/packages/auth/server/lib/session/session.ts @@ -2,6 +2,7 @@ import { sha256 } from '@oslojs/crypto/sha2'; import { encodeBase32LowerCaseNoPadding, encodeHexLowerCase } from '@oslojs/encoding'; import { type Session, type User, UserSecurityAuditLogType } from '@prisma/client'; +import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata'; import { prisma } from '@documenso/prisma'; @@ -129,18 +130,46 @@ export const validateSessionToken = async (token: string): Promise => { - const session = await prisma.session.delete({ where: { id: sessionId } }); +type InvalidateSessionsOptions = { + userId: number; + sessionIds: string[]; + metadata: RequestMetadata; + isRevoke?: boolean; +}; - await prisma.userSecurityAuditLog.create({ - data: { - userId: session.userId, - ipAddress: metadata.ipAddress, - userAgent: metadata.userAgent, - type: UserSecurityAuditLogType.SIGN_OUT, - }, +export const invalidateSessions = async ({ + userId, + sessionIds, + metadata, + isRevoke, +}: InvalidateSessionsOptions): Promise => { + if (sessionIds.length === 0) { + return; + } + + await prisma.$transaction(async (tx) => { + const { count } = await tx.session.deleteMany({ + where: { + userId, + id: { in: sessionIds }, + }, + }); + + if (count !== sessionIds.length) { + throw new AppError(AppErrorCode.INVALID_REQUEST, { + message: 'One or more sessions are not valid.', + }); + } + + await tx.userSecurityAuditLog.createMany({ + data: sessionIds.map(() => ({ + userId, + ipAddress: metadata.ipAddress, + userAgent: metadata.userAgent, + type: isRevoke + ? UserSecurityAuditLogType.SESSION_REVOKED + : UserSecurityAuditLogType.SIGN_OUT, + })), + }); }); }; diff --git a/packages/auth/server/lib/utils/get-session.ts b/packages/auth/server/lib/utils/get-session.ts index 66ff5ab99..691dd3d78 100644 --- a/packages/auth/server/lib/utils/get-session.ts +++ b/packages/auth/server/lib/utils/get-session.ts @@ -1,6 +1,8 @@ +import type { Session } from '@prisma/client'; import type { Context } from 'hono'; import { AppError } from '@documenso/lib/errors/app-error'; +import { prisma } from '@documenso/prisma'; import { AuthenticationErrorCode } from '../errors/error-codes'; import type { SessionValidationResult } from '../session/session'; @@ -37,6 +39,33 @@ export const getOptionalSession = async ( return await validateSessionToken(sessionId); }; +export type ActiveSession = Omit; + +export const getActiveSessions = async (c: Context | Request): Promise => { + const { user } = await getSession(c); + + return await prisma.session.findMany({ + where: { + userId: user.id, + expiresAt: { + gt: new Date(), + }, + }, + orderBy: { + updatedAt: 'desc', + }, + select: { + id: true, + userId: true, + expiresAt: true, + updatedAt: true, + createdAt: true, + ipAddress: true, + userAgent: true, + }, + }); +}; + /** * Todo: (RR7) Rethink, this is pretty sketchy. */ diff --git a/packages/auth/server/routes/session.ts b/packages/auth/server/routes/session.ts index c10d16883..fc0ebec88 100644 --- a/packages/auth/server/routes/session.ts +++ b/packages/auth/server/routes/session.ts @@ -2,7 +2,7 @@ import { Hono } from 'hono'; import superjson from 'superjson'; import type { SessionValidationResult } from '../lib/session/session'; -import { getOptionalSession } from '../lib/utils/get-session'; +import { getActiveSessions, getOptionalSession } from '../lib/utils/get-session'; export const sessionRoute = new Hono() .get('/session', async (c) => { @@ -10,6 +10,11 @@ export const sessionRoute = new Hono() return c.json(session); }) + .get('/sessions', async (c) => { + const sessions = await getActiveSessions(c); + + return c.json(superjson.serialize({ sessions })); + }) .get('/session-json', async (c) => { const session: SessionValidationResult = await getOptionalSession(c); diff --git a/packages/auth/server/routes/sign-out.ts b/packages/auth/server/routes/sign-out.ts index 873adb743..cd854b328 100644 --- a/packages/auth/server/routes/sign-out.ts +++ b/packages/auth/server/routes/sign-out.ts @@ -1,27 +1,114 @@ +import { sValidator } from '@hono/standard-validator'; import { Hono } from 'hono'; +import { z } from 'zod'; -import { invalidateSession, validateSessionToken } from '../lib/session/session'; +import { prisma } from '@documenso/prisma'; + +import { invalidateSessions, validateSessionToken } from '../lib/session/session'; import { deleteSessionCookie, getSessionCookie } from '../lib/session/session-cookies'; import type { HonoAuthContext } from '../types/context'; -export const signOutRoute = new Hono().post('/signout', async (c) => { - const metadata = c.get('requestMetadata'); - - const sessionId = await getSessionCookie(c); - - if (!sessionId) { - return new Response('No session found', { status: 401 }); - } - - const { session } = await validateSessionToken(sessionId); - - if (!session) { - return new Response('No session found', { status: 401 }); - } - - await invalidateSession(session.id, metadata); - - deleteSessionCookie(c); - - return c.status(200); +const ZSignoutSessionSchema = z.object({ + sessionId: z.string().trim().min(1), }); + +export const signOutRoute = new Hono() + .post('/signout', async (c) => { + const metadata = c.get('requestMetadata'); + + const sessionToken = await getSessionCookie(c); + + if (!sessionToken) { + return new Response('No session found', { status: 401 }); + } + + const { session } = await validateSessionToken(sessionToken); + + if (!session) { + deleteSessionCookie(c); + return new Response('No session found', { status: 401 }); + } + + await invalidateSessions({ + userId: session.userId, + sessionIds: [session.id], + metadata, + isRevoke: false, + }); + + deleteSessionCookie(c); + + return c.status(200); + }) + .post('/signout-all', async (c) => { + const metadata = c.get('requestMetadata'); + + const sessionToken = await getSessionCookie(c); + + if (!sessionToken) { + return new Response('No session found', { status: 401 }); + } + + const { session } = await validateSessionToken(sessionToken); + + if (!session) { + deleteSessionCookie(c); + return new Response('No session found', { status: 401 }); + } + + const userId = session.userId; + + const userSessionIds = await prisma.session + .findMany({ + where: { + userId, + id: { + not: session.id, + }, + }, + select: { + id: true, + }, + }) + .then((sessions) => sessions.map((session) => session.id)); + + await invalidateSessions({ + userId, + sessionIds: userSessionIds, + metadata, + isRevoke: true, + }); + + return c.status(200); + }) + .post('/signout-session', sValidator('json', ZSignoutSessionSchema), async (c) => { + const metadata = c.get('requestMetadata'); + + const { sessionId: sessionIdToRevoke } = c.req.valid('json'); + + const sessionToken = await getSessionCookie(c); + + if (!sessionToken) { + return new Response('No session found', { status: 401 }); + } + + const { session } = await validateSessionToken(sessionToken); + + if (!session) { + deleteSessionCookie(c); + return new Response('No session found', { status: 401 }); + } + + await invalidateSessions({ + userId: session.userId, + sessionIds: [sessionIdToRevoke], + metadata, + isRevoke: true, + }); + + if (session.id === sessionIdToRevoke) { + deleteSessionCookie(c); + } + + return c.status(200); + }); diff --git a/packages/lib/constants/auth.ts b/packages/lib/constants/auth.ts index 2f7b9b0e2..38e1c3998 100644 --- a/packages/lib/constants/auth.ts +++ b/packages/lib/constants/auth.ts @@ -31,6 +31,7 @@ export const USER_SECURITY_AUDIT_LOG_MAP: Record = { PASSKEY_UPDATED: 'Passkey updated', PASSWORD_RESET: 'Password reset', PASSWORD_UPDATE: 'Password updated', + SESSION_REVOKED: 'Session revoked', SIGN_OUT: 'Signed Out', SIGN_IN: 'Signed In', SIGN_IN_FAIL: 'Sign in attempt failed', diff --git a/packages/prisma/migrations/20250611052525_add_session_revoke_enum/migration.sql b/packages/prisma/migrations/20250611052525_add_session_revoke_enum/migration.sql new file mode 100644 index 000000000..fd5868885 --- /dev/null +++ b/packages/prisma/migrations/20250611052525_add_session_revoke_enum/migration.sql @@ -0,0 +1,2 @@ +-- AlterEnum +ALTER TYPE "UserSecurityAuditLogType" ADD VALUE 'SESSION_REVOKED'; diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index d16fcd13d..d990120b7 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -97,6 +97,7 @@ enum UserSecurityAuditLogType { PASSKEY_UPDATED PASSWORD_RESET PASSWORD_UPDATE + SESSION_REVOKED SIGN_OUT SIGN_IN SIGN_IN_FAIL