mirror of
https://github.com/documenso/documenso.git
synced 2026-06-22 04:12:06 +10:00
chore: disabled account enforcement (#2882)
This commit is contained in:
@@ -14,7 +14,7 @@ import { AUTH_SESSION_LIFETIME } from '../../config';
|
|||||||
*/
|
*/
|
||||||
export type SessionUser = Pick<
|
export type SessionUser = Pick<
|
||||||
User,
|
User,
|
||||||
'id' | 'name' | 'email' | 'emailVerified' | 'avatarImageId' | 'twoFactorEnabled' | 'roles' | 'signature'
|
'id' | 'name' | 'email' | 'emailVerified' | 'avatarImageId' | 'twoFactorEnabled' | 'roles' | 'signature' | 'disabled'
|
||||||
>;
|
>;
|
||||||
|
|
||||||
export type SessionValidationResult =
|
export type SessionValidationResult =
|
||||||
@@ -86,6 +86,7 @@ export const validateSessionToken = async (token: string): Promise<SessionValida
|
|||||||
twoFactorEnabled: true,
|
twoFactorEnabled: true,
|
||||||
roles: true,
|
roles: true,
|
||||||
signature: true,
|
signature: true,
|
||||||
|
disabled: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { assertUserNotDisabledById } from '@documenso/lib/server-only/user/assert-user-not-disabled';
|
||||||
import type { Context } from 'hono';
|
import type { Context } from 'hono';
|
||||||
|
|
||||||
import type { HonoAuthContext } from '../../types/context';
|
import type { HonoAuthContext } from '../../types/context';
|
||||||
@@ -10,8 +11,15 @@ type AuthorizeUser = {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Handles creating a session.
|
* Handles creating a session.
|
||||||
|
*
|
||||||
|
* Refuses to issue a session for a disabled account. This is the single
|
||||||
|
* chokepoint shared by every sign-in path (email/password, passkey, OAuth,
|
||||||
|
* OIDC, organisation OIDC), so the guard belongs here rather than in each
|
||||||
|
* caller.
|
||||||
*/
|
*/
|
||||||
export const onAuthorize = async (user: AuthorizeUser, c: Context<HonoAuthContext>) => {
|
export const onAuthorize = async (user: AuthorizeUser, c: Context<HonoAuthContext>) => {
|
||||||
|
await assertUserNotDisabledById({ userId: user.userId });
|
||||||
|
|
||||||
const metadata = c.get('requestMetadata');
|
const metadata = c.get('requestMetadata');
|
||||||
|
|
||||||
const sessionToken = generateSessionToken();
|
const sessionToken = generateSessionToken();
|
||||||
|
|||||||
@@ -171,12 +171,8 @@ export const emailPasswordRoute = new Hono<HonoAuthContext>()
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (user.disabled) {
|
// The disabled check now lives inside `onAuthorize` so every sign-in path
|
||||||
throw new AppError('ACCOUNT_DISABLED', {
|
// (password, passkey, OAuth, OIDC) shares the same enforcement.
|
||||||
message: 'Account disabled',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
await onAuthorize({ userId: user.id }, c);
|
await onAuthorize({ userId: user.id }, c);
|
||||||
|
|
||||||
return c.text('', 201);
|
return c.text('', 201);
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
|
|||||||
import { assertOrgEmailSendAllowed } from '../email/assert-org-email-send-allowed';
|
import { assertOrgEmailSendAllowed } from '../email/assert-org-email-send-allowed';
|
||||||
import { getEmailContext } from '../email/get-email-context';
|
import { getEmailContext } from '../email/get-email-context';
|
||||||
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
|
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
|
||||||
|
import { assertUserNotDisabled } from '../user/assert-user-not-disabled';
|
||||||
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
|
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
|
||||||
|
|
||||||
export type ResendDocumentOptions = {
|
export type ResendDocumentOptions = {
|
||||||
@@ -49,9 +50,14 @@ export const resendDocument = async ({ id, userId, recipients, teamId, requestMe
|
|||||||
id: true,
|
id: true,
|
||||||
email: true,
|
email: true,
|
||||||
name: true,
|
name: true,
|
||||||
|
disabled: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Refuse to resend on behalf of a disabled account. Guards
|
||||||
|
// document.redistribute / envelope.redistribute and the API v1 equivalent.
|
||||||
|
assertUserNotDisabled(user);
|
||||||
|
|
||||||
const { envelopeWhereInput } = await getEnvelopeWhereInput({
|
const { envelopeWhereInput } = await getEnvelopeWhereInput({
|
||||||
id,
|
id,
|
||||||
type: EnvelopeType.DOCUMENT,
|
type: EnvelopeType.DOCUMENT,
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ import { toCheckboxCustomText, toRadioCustomText } from '../../utils/fields';
|
|||||||
import { getRecipientsWithMissingFields, isRecipientEmailValidForSending } from '../../utils/recipients';
|
import { getRecipientsWithMissingFields, isRecipientEmailValidForSending } from '../../utils/recipients';
|
||||||
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
|
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
|
||||||
import { insertFormValuesInPdf } from '../pdf/insert-form-values-in-pdf';
|
import { insertFormValuesInPdf } from '../pdf/insert-form-values-in-pdf';
|
||||||
|
import { assertUserNotDisabledById } from '../user/assert-user-not-disabled';
|
||||||
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
|
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
|
||||||
|
|
||||||
export type SendDocumentOptions = {
|
export type SendDocumentOptions = {
|
||||||
@@ -50,6 +51,11 @@ export type SendDocumentOptions = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const sendDocument = async ({ id, userId, teamId, sendEmail, requestMetadata }: SendDocumentOptions) => {
|
export const sendDocument = async ({ id, userId, teamId, sendEmail, requestMetadata }: SendDocumentOptions) => {
|
||||||
|
// Refuse to send on behalf of a disabled account. Guards distribute /
|
||||||
|
// redistribute / template-use routes, the bulk-send job, and direct
|
||||||
|
// templates that auto-send on creation.
|
||||||
|
await assertUserNotDisabledById({ userId });
|
||||||
|
|
||||||
const { envelopeWhereInput } = await getEnvelopeWhereInput({
|
const { envelopeWhereInput } = await getEnvelopeWhereInput({
|
||||||
id,
|
id,
|
||||||
type: EnvelopeType.DOCUMENT,
|
type: EnvelopeType.DOCUMENT,
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ import { createDocumentAuthOptions, createRecipientAuthOptions } from '../../uti
|
|||||||
import { buildTeamWhereQuery } from '../../utils/teams';
|
import { buildTeamWhereQuery } from '../../utils/teams';
|
||||||
import { incrementDocumentId, incrementTemplateId } from '../envelope/increment-id';
|
import { incrementDocumentId, incrementTemplateId } from '../envelope/increment-id';
|
||||||
import { getTeamSettings } from '../team/get-team-settings';
|
import { getTeamSettings } from '../team/get-team-settings';
|
||||||
|
import { assertUserNotDisabledById } from '../user/assert-user-not-disabled';
|
||||||
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
|
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
|
||||||
|
|
||||||
type CreateEnvelopeRecipientFieldOptions = TFieldAndMeta & {
|
type CreateEnvelopeRecipientFieldOptions = TFieldAndMeta & {
|
||||||
@@ -116,6 +117,11 @@ export const createEnvelope = async ({
|
|||||||
internalVersion,
|
internalVersion,
|
||||||
bypassDefaultRecipients = false,
|
bypassDefaultRecipients = false,
|
||||||
}: CreateEnvelopeOptions) => {
|
}: CreateEnvelopeOptions) => {
|
||||||
|
// Refuse to create on behalf of a disabled account. Guards every route that
|
||||||
|
// funnels through here (document.create, envelope.use, template create,
|
||||||
|
// embedding template/document create, API v1) and the seed/job paths.
|
||||||
|
await assertUserNotDisabledById({ userId });
|
||||||
|
|
||||||
const {
|
const {
|
||||||
type,
|
type,
|
||||||
title,
|
title,
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
|
||||||
|
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Throws if the supplied user object is disabled.
|
||||||
|
*
|
||||||
|
* Synchronous variant for hot paths where the `disabled` field has already
|
||||||
|
* been loaded (e.g. TRPC middleware where the user comes from the session
|
||||||
|
* query or API token lookup).
|
||||||
|
*/
|
||||||
|
export const assertUserNotDisabled = (user: { disabled: boolean }): void => {
|
||||||
|
if (user.disabled) {
|
||||||
|
throw new AppError('ACCOUNT_DISABLED', {
|
||||||
|
message: 'Account disabled',
|
||||||
|
statusCode: 403,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AssertUserNotDisabledByIdOptions = {
|
||||||
|
userId: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Throws if the user with the given id does not exist or is disabled.
|
||||||
|
*
|
||||||
|
* Used as a defence-in-depth guard for sign-in chokepoints and server-side
|
||||||
|
* actions that should not be performed on behalf of a disabled account
|
||||||
|
* (e.g. creating or sending documents). It deliberately re-queries from the
|
||||||
|
* database rather than relying on cached context so a freshly-disabled user
|
||||||
|
* cannot continue to act through a stale session or token.
|
||||||
|
*/
|
||||||
|
export const assertUserNotDisabledById = async ({ userId }: AssertUserNotDisabledByIdOptions): Promise<void> => {
|
||||||
|
const user = await prisma.user.findFirst({
|
||||||
|
where: { id: userId },
|
||||||
|
select: { disabled: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||||
|
message: 'User not found',
|
||||||
|
statusCode: 404,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
assertUserNotDisabled(user);
|
||||||
|
};
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { AppError, genericErrorCodeToTrpcErrorCodeMap } from '@documenso/lib/errors/app-error';
|
import { AppError, genericErrorCodeToTrpcErrorCodeMap } from '@documenso/lib/errors/app-error';
|
||||||
import { getApiTokenByToken } from '@documenso/lib/server-only/public-api/get-api-token-by-token';
|
import { getApiTokenByToken } from '@documenso/lib/server-only/public-api/get-api-token-by-token';
|
||||||
|
import { assertUserNotDisabled } from '@documenso/lib/server-only/user/assert-user-not-disabled';
|
||||||
import type { TrpcApiLog } from '@documenso/lib/types/api-logs';
|
import type { TrpcApiLog } from '@documenso/lib/types/api-logs';
|
||||||
import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
||||||
import { alphaid } from '@documenso/lib/universal/id';
|
import { alphaid } from '@documenso/lib/universal/id';
|
||||||
@@ -96,6 +97,10 @@ export const authenticatedMiddleware = t.middleware(async ({ ctx, next, path, me
|
|||||||
|
|
||||||
const apiToken = await getApiTokenByToken({ token });
|
const apiToken = await getApiTokenByToken({ token });
|
||||||
|
|
||||||
|
// Reject API requests from a disabled account. The token may still be
|
||||||
|
// present in the DB (e.g. before `disableUser` runs) so we enforce here.
|
||||||
|
assertUserNotDisabled(apiToken.user);
|
||||||
|
|
||||||
const trpcApiV2Logger = ctx.logger.child({
|
const trpcApiV2Logger = ctx.logger.child({
|
||||||
...baseLogAttributes,
|
...baseLogAttributes,
|
||||||
auth: 'api',
|
auth: 'api',
|
||||||
@@ -140,6 +145,11 @@ export const authenticatedMiddleware = t.middleware(async ({ ctx, next, path, me
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reject session requests from a disabled account. The session may still be
|
||||||
|
// valid (sessions aren't invalidated by `disableUser`), so we gate every
|
||||||
|
// authenticated TRPC call here.
|
||||||
|
assertUserNotDisabled(ctx.user);
|
||||||
|
|
||||||
// Recreate the logger with a sub request ID to differentiate between batched
|
// Recreate the logger with a sub request ID to differentiate between batched
|
||||||
// requests, as well as identifying attributes so every subsequent log line
|
// requests, as well as identifying attributes so every subsequent log line
|
||||||
// (including errors) inherits them.
|
// (including errors) inherits them.
|
||||||
@@ -199,6 +209,11 @@ export const maybeAuthenticatedMiddleware = t.middleware(async ({ ctx, next, pat
|
|||||||
|
|
||||||
const apiToken = await getApiTokenByToken({ token });
|
const apiToken = await getApiTokenByToken({ token });
|
||||||
|
|
||||||
|
// Reject API requests from a disabled account. Presenting an API token is
|
||||||
|
// an explicit attempt to act under that account, so we don't downgrade to
|
||||||
|
// anonymous here — we reject.
|
||||||
|
assertUserNotDisabled(apiToken.user);
|
||||||
|
|
||||||
// Attach identifying attributes to the logger so every subsequent log line
|
// Attach identifying attributes to the logger so every subsequent log line
|
||||||
// within this request (including errors) inherits them.
|
// within this request (including errors) inherits them.
|
||||||
const trpcApiV2Logger = ctx.logger.child({
|
const trpcApiV2Logger = ctx.logger.child({
|
||||||
@@ -238,9 +253,17 @@ export const maybeAuthenticatedMiddleware = t.middleware(async ({ ctx, next, pat
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Treat a disabled session as anonymous. Most routes wired through
|
||||||
|
// `maybeAuthenticatedProcedure` are signer/invite flows that key off an
|
||||||
|
// input token rather than `ctx.user`, so downgrading lets those keep
|
||||||
|
// working while routes that genuinely need an account naturally fall
|
||||||
|
// through to their own auth checks.
|
||||||
|
const sessionUser = ctx.user && !ctx.user.disabled ? ctx.user : null;
|
||||||
|
const sessionRecord = sessionUser ? ctx.session : null;
|
||||||
|
|
||||||
// Resolve `auth` once so it stays in sync between the logger bindings and
|
// Resolve `auth` once so it stays in sync between the logger bindings and
|
||||||
// the outgoing metadata.
|
// the outgoing metadata.
|
||||||
const auth = ctx.session ? 'session' : null;
|
const auth = sessionRecord ? 'session' : null;
|
||||||
|
|
||||||
// Recreate the logger with a sub request ID to differentiate between batched
|
// Recreate the logger with a sub request ID to differentiate between batched
|
||||||
// requests, as well as identifying attributes so every subsequent log line
|
// requests, as well as identifying attributes so every subsequent log line
|
||||||
@@ -249,7 +272,7 @@ export const maybeAuthenticatedMiddleware = t.middleware(async ({ ctx, next, pat
|
|||||||
...baseLogAttributes,
|
...baseLogAttributes,
|
||||||
auth,
|
auth,
|
||||||
nonBatchedRequestId: alphaid(),
|
nonBatchedRequestId: alphaid(),
|
||||||
userId: ctx.user?.id,
|
userId: sessionUser?.id,
|
||||||
apiTokenId: null,
|
apiTokenId: null,
|
||||||
} satisfies TrpcApiLog);
|
} satisfies TrpcApiLog);
|
||||||
|
|
||||||
@@ -261,15 +284,15 @@ export const maybeAuthenticatedMiddleware = t.middleware(async ({ ctx, next, pat
|
|||||||
ctx: {
|
ctx: {
|
||||||
...ctx,
|
...ctx,
|
||||||
logger: trpcSessionLogger,
|
logger: trpcSessionLogger,
|
||||||
user: ctx.user,
|
user: sessionUser,
|
||||||
session: ctx.session,
|
session: sessionRecord,
|
||||||
metadata: {
|
metadata: {
|
||||||
...ctx.metadata,
|
...ctx.metadata,
|
||||||
auditUser: ctx.user
|
auditUser: sessionUser
|
||||||
? {
|
? {
|
||||||
id: ctx.user.id,
|
id: sessionUser.id,
|
||||||
name: ctx.user.name,
|
name: sessionUser.name,
|
||||||
email: ctx.user.email,
|
email: sessionUser.email,
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
auth,
|
auth,
|
||||||
@@ -286,6 +309,9 @@ export const adminMiddleware = t.middleware(async ({ ctx, next, path }) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Disabled admins shouldn't be able to do anything either.
|
||||||
|
assertUserNotDisabled(ctx.user);
|
||||||
|
|
||||||
const isUserAdmin = isAdmin(ctx.user);
|
const isUserAdmin = isAdmin(ctx.user);
|
||||||
|
|
||||||
if (!isUserAdmin) {
|
if (!isUserAdmin) {
|
||||||
|
|||||||
Reference in New Issue
Block a user