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,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 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<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);
});

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