mirror of
https://github.com/documenso/documenso.git
synced 2025-11-13 00:03:33 +10:00
feat: sign out of all sessions (#1797)
This commit is contained in:
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -171,6 +171,27 @@ export default function SettingsSecurity({ loaderData }: Route.ComponentProps) {
|
|||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</Alert>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
82
packages/app-tests/e2e/user/auth-sessions.spec.ts
Normal file
82
packages/app-tests/e2e/user/auth-sessions.spec.ts
Normal 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');
|
||||||
|
});
|
||||||
@ -7,6 +7,7 @@ import { AppError } from '@documenso/lib/errors/app-error';
|
|||||||
|
|
||||||
import type { AuthAppType } from '../server';
|
import type { AuthAppType } from '../server';
|
||||||
import type { SessionValidationResult } from '../server/lib/session/session';
|
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 { handleSignInRedirect } from '../server/lib/utils/redirect';
|
||||||
import type {
|
import type {
|
||||||
TDisableTwoFactorRequestSchema,
|
TDisableTwoFactorRequestSchema,
|
||||||
@ -47,6 +48,26 @@ export class AuthClient {
|
|||||||
window.location.href = redirectPath ?? this.signOutredirectPath;
|
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() {
|
public async getSession() {
|
||||||
const response = await this.client['session-json'].$get();
|
const response = await this.client['session-json'].$get();
|
||||||
|
|
||||||
@ -57,6 +78,16 @@ export class AuthClient {
|
|||||||
return superjson.deserialize<SessionValidationResult>(result);
|
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> {
|
private async handleError<T>(response: ClientResponse<T>): Promise<void> {
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const error = await response.json();
|
const error = await response.json();
|
||||||
|
|||||||
@ -1,2 +1 @@
|
|||||||
export * from './server/lib/errors/errors';
|
|
||||||
export * from './server/lib/errors/error-codes';
|
export * from './server/lib/errors/error-codes';
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import { sha256 } from '@oslojs/crypto/sha2';
|
|||||||
import { encodeBase32LowerCaseNoPadding, encodeHexLowerCase } from '@oslojs/encoding';
|
import { encodeBase32LowerCaseNoPadding, encodeHexLowerCase } from '@oslojs/encoding';
|
||||||
import { type Session, type User, UserSecurityAuditLogType } from '@prisma/client';
|
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 type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
||||||
import { prisma } from '@documenso/prisma';
|
import { prisma } from '@documenso/prisma';
|
||||||
|
|
||||||
@ -129,18 +130,46 @@ export const validateSessionToken = async (token: string): Promise<SessionValida
|
|||||||
return { session, user, isAuthenticated: true };
|
return { session, user, isAuthenticated: true };
|
||||||
};
|
};
|
||||||
|
|
||||||
export const invalidateSession = async (
|
type InvalidateSessionsOptions = {
|
||||||
sessionId: string,
|
userId: number;
|
||||||
metadata: RequestMetadata,
|
sessionIds: string[];
|
||||||
): Promise<void> => {
|
metadata: RequestMetadata;
|
||||||
const session = await prisma.session.delete({ where: { id: sessionId } });
|
isRevoke?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
await prisma.userSecurityAuditLog.create({
|
export const invalidateSessions = async ({
|
||||||
data: {
|
userId,
|
||||||
userId: session.userId,
|
sessionIds,
|
||||||
ipAddress: metadata.ipAddress,
|
metadata,
|
||||||
userAgent: metadata.userAgent,
|
isRevoke,
|
||||||
type: UserSecurityAuditLogType.SIGN_OUT,
|
}: 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,
|
||||||
|
})),
|
||||||
|
});
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,6 +1,8 @@
|
|||||||
|
import type { Session } from '@prisma/client';
|
||||||
import type { Context } from 'hono';
|
import type { Context } from 'hono';
|
||||||
|
|
||||||
import { AppError } from '@documenso/lib/errors/app-error';
|
import { AppError } from '@documenso/lib/errors/app-error';
|
||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
|
||||||
import { AuthenticationErrorCode } from '../errors/error-codes';
|
import { AuthenticationErrorCode } from '../errors/error-codes';
|
||||||
import type { SessionValidationResult } from '../session/session';
|
import type { SessionValidationResult } from '../session/session';
|
||||||
@ -37,6 +39,33 @@ export const getOptionalSession = async (
|
|||||||
return await validateSessionToken(sessionId);
|
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.
|
* Todo: (RR7) Rethink, this is pretty sketchy.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import { Hono } from 'hono';
|
|||||||
import superjson from 'superjson';
|
import superjson from 'superjson';
|
||||||
|
|
||||||
import type { SessionValidationResult } from '../lib/session/session';
|
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()
|
export const sessionRoute = new Hono()
|
||||||
.get('/session', async (c) => {
|
.get('/session', async (c) => {
|
||||||
@ -10,6 +10,11 @@ export const sessionRoute = new Hono()
|
|||||||
|
|
||||||
return c.json(session);
|
return c.json(session);
|
||||||
})
|
})
|
||||||
|
.get('/sessions', async (c) => {
|
||||||
|
const sessions = await getActiveSessions(c);
|
||||||
|
|
||||||
|
return c.json(superjson.serialize({ sessions }));
|
||||||
|
})
|
||||||
.get('/session-json', async (c) => {
|
.get('/session-json', async (c) => {
|
||||||
const session: SessionValidationResult = await getOptionalSession(c);
|
const session: SessionValidationResult = await getOptionalSession(c);
|
||||||
|
|
||||||
|
|||||||
@ -1,27 +1,114 @@
|
|||||||
|
import { sValidator } from '@hono/standard-validator';
|
||||||
import { Hono } from 'hono';
|
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 { deleteSessionCookie, getSessionCookie } from '../lib/session/session-cookies';
|
||||||
import type { HonoAuthContext } from '../types/context';
|
import type { HonoAuthContext } from '../types/context';
|
||||||
|
|
||||||
export const signOutRoute = new Hono<HonoAuthContext>().post('/signout', async (c) => {
|
const ZSignoutSessionSchema = z.object({
|
||||||
const metadata = c.get('requestMetadata');
|
sessionId: z.string().trim().min(1),
|
||||||
|
|
||||||
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);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const signOutRoute = new Hono<HonoAuthContext>()
|
||||||
|
.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);
|
||||||
|
});
|
||||||
|
|||||||
@ -31,6 +31,7 @@ export const USER_SECURITY_AUDIT_LOG_MAP: Record<string, string> = {
|
|||||||
PASSKEY_UPDATED: 'Passkey updated',
|
PASSKEY_UPDATED: 'Passkey updated',
|
||||||
PASSWORD_RESET: 'Password reset',
|
PASSWORD_RESET: 'Password reset',
|
||||||
PASSWORD_UPDATE: 'Password updated',
|
PASSWORD_UPDATE: 'Password updated',
|
||||||
|
SESSION_REVOKED: 'Session revoked',
|
||||||
SIGN_OUT: 'Signed Out',
|
SIGN_OUT: 'Signed Out',
|
||||||
SIGN_IN: 'Signed In',
|
SIGN_IN: 'Signed In',
|
||||||
SIGN_IN_FAIL: 'Sign in attempt failed',
|
SIGN_IN_FAIL: 'Sign in attempt failed',
|
||||||
|
|||||||
@ -0,0 +1,2 @@
|
|||||||
|
-- AlterEnum
|
||||||
|
ALTER TYPE "UserSecurityAuditLogType" ADD VALUE 'SESSION_REVOKED';
|
||||||
@ -97,6 +97,7 @@ enum UserSecurityAuditLogType {
|
|||||||
PASSKEY_UPDATED
|
PASSKEY_UPDATED
|
||||||
PASSWORD_RESET
|
PASSWORD_RESET
|
||||||
PASSWORD_UPDATE
|
PASSWORD_UPDATE
|
||||||
|
SESSION_REVOKED
|
||||||
SIGN_OUT
|
SIGN_OUT
|
||||||
SIGN_IN
|
SIGN_IN
|
||||||
SIGN_IN_FAIL
|
SIGN_IN_FAIL
|
||||||
|
|||||||
Reference in New Issue
Block a user