feat: sign out of all sessions (#1797)

This commit is contained in:
Ephraim Duncan
2025-06-11 07:57:38 +00:00
committed by GitHub
parent e3ce7f94e6
commit 400d2a2b1a
13 changed files with 608 additions and 35 deletions

View File

@ -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<unknown>;
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 (
<Dialog open={isOpen} onOpenChange={(value) => (isLoading ? undefined : setIsOpen(value))}>
<DialogTrigger asChild>
<Button variant="secondary" disabled={disabled}>
<Trans>Revoke all sessions</Trans>
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>
<Trans>Revoke all sessions</Trans>
</DialogTitle>
<DialogDescription>
<Trans>
This will sign you out of all other devices. You will need to sign in again on those
devices to continue using your account.
</Trans>
</DialogDescription>
</DialogHeader>
<DialogFooter>
<DialogClose asChild>
<Button variant="secondary" disabled={isLoading}>
<Trans>Cancel</Trans>
</Button>
</DialogClose>
<Button loading={isLoading} variant="destructive" onClick={handleSignOutAllSessions}>
<Trans>Revoke all sessions</Trans>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View File

@ -171,6 +171,27 @@ export default function SettingsSecurity({ loaderData }: Route.ComponentProps) {
</Link>
</Button>
</Alert>
<Alert
className="mt-6 flex flex-col justify-between p-6 sm:flex-row sm:items-center"
variant="neutral"
>
<div className="mb-4 mr-4 sm:mb-0">
<AlertTitle>
<Trans>Active sessions</Trans>
</AlertTitle>
<AlertDescription className="mr-2">
<Trans>View and manage all active sessions for your account.</Trans>
</AlertDescription>
</div>
<Button asChild variant="outline" className="bg-background">
<Link to="/settings/security/sessions">
<Trans>Manage sessions</Trans>
</Link>
</Button>
</Alert>
</div>
);
}

View File

@ -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 (
<div className="flex items-center gap-2">
<span>
{browser} ({os})
</span>
{isCurrentSession && (
<Badge>
<Trans>Current</Trans>
</Badge>
)}
</div>
);
},
},
{
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 }) => (
<SessionRevokeButton
sessionId={row.original.id}
isCurrentSession={row.original.id === session?.id}
onSuccess={refetch}
/>
),
},
] satisfies DataTableColumnDef<(typeof results)[number]>[];
}, []);
return (
<div>
<SettingsHeader
title={t`Active sessions`}
subtitle={t`View and manage all active sessions for your account.`}
>
<SessionLogoutAllDialog onSuccess={refetch} disabled={results.length === 1 || isLoading} />
</SettingsHeader>
<div className="mt-4">
<DataTable
columns={columns}
data={results}
hasFilters={false}
error={{
enable: isLoadingError,
}}
skeleton={{
enable: isLoading,
rows: 3,
component: (
<>
<TableCell>
<Skeleton className="h-4 w-40 rounded-full" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-24 rounded-full" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-24 rounded-full" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-24 rounded-full" />
</TableCell>
<TableCell>
<Skeleton className="h-8 w-16 rounded" />
</TableCell>
</>
),
}}
/>
</div>
</div>
);
}
type SessionRevokeButtonProps = {
sessionId: string;
isCurrentSession: boolean;
onSuccess: () => Promise<unknown>;
};
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 (
<Button variant="destructive" size="sm" onClick={handleRevoke} loading={isLoading}>
<Trans>Revoke</Trans>
</Button>
);
};

View File

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

View File

@ -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<SessionValidationResult>(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<T>(response: ClientResponse<T>): Promise<void> {
if (!response.ok) {
const error = await response.json();

View File

@ -1,2 +1 @@
export * from './server/lib/errors/errors';
export * from './server/lib/errors/error-codes';

View File

@ -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<SessionValida
return { session, user, isAuthenticated: true };
};
export const invalidateSession = async (
sessionId: string,
metadata: RequestMetadata,
): Promise<void> => {
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<void> => {
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,
})),
});
});
};

View File

@ -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<Session, 'sessionToken'>;
export const getActiveSessions = async (c: Context | Request): Promise<ActiveSession[]> => {
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.
*/

View File

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

View File

@ -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<HonoAuthContext>().post('/signout', async (c) => {
const ZSignoutSessionSchema = z.object({
sessionId: z.string().trim().min(1),
});
export const signOutRoute = new Hono<HonoAuthContext>()
.post('/signout', async (c) => {
const metadata = c.get('requestMetadata');
const sessionId = await getSessionCookie(c);
const sessionToken = await getSessionCookie(c);
if (!sessionId) {
if (!sessionToken) {
return new Response('No session found', { status: 401 });
}
const { session } = await validateSessionToken(sessionId);
const { session } = await validateSessionToken(sessionToken);
if (!session) {
deleteSessionCookie(c);
return new Response('No session found', { status: 401 });
}
await invalidateSession(session.id, metadata);
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);
});

View File

@ -31,6 +31,7 @@ export const USER_SECURITY_AUDIT_LOG_MAP: Record<string, string> = {
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',

View File

@ -0,0 +1,2 @@
-- AlterEnum
ALTER TYPE "UserSecurityAuditLogType" ADD VALUE 'SESSION_REVOKED';

View File

@ -97,6 +97,7 @@ enum UserSecurityAuditLogType {
PASSKEY_UPDATED
PASSWORD_RESET
PASSWORD_UPDATE
SESSION_REVOKED
SIGN_OUT
SIGN_IN
SIGN_IN_FAIL