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:
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 { 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();
|
||||
|
||||
@ -1,2 +1 @@
|
||||
export * from './server/lib/errors/errors';
|
||||
export * from './server/lib/errors/error-codes';
|
||||
|
||||
@ -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,
|
||||
})),
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
@ -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.
|
||||
*/
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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);
|
||||
});
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -0,0 +1,2 @@
|
||||
-- AlterEnum
|
||||
ALTER TYPE "UserSecurityAuditLogType" ADD VALUE 'SESSION_REVOKED';
|
||||
@ -97,6 +97,7 @@ enum UserSecurityAuditLogType {
|
||||
PASSKEY_UPDATED
|
||||
PASSWORD_RESET
|
||||
PASSWORD_UPDATE
|
||||
SESSION_REVOKED
|
||||
SIGN_OUT
|
||||
SIGN_IN
|
||||
SIGN_IN_FAIL
|
||||
|
||||
Reference in New Issue
Block a user