mirror of
https://github.com/documenso/documenso.git
synced 2026-09-30 00:34:35 +10:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ef31a16b99 | ||
|
|
fe2641ff57 | ||
|
|
df815e5b44 |
@@ -30,6 +30,26 @@ jobs:
|
||||
- name: Build app
|
||||
run: npm run build
|
||||
|
||||
unit_tests:
|
||||
name: Unit Tests
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 2
|
||||
|
||||
- uses: ./.github/actions/node-install
|
||||
|
||||
- name: Copy env
|
||||
run: cp .env.example .env
|
||||
|
||||
# Includes the 2FA enforcement drift guard (packages/trpc), which is the
|
||||
# only check that catches a session route without enforcement middleware.
|
||||
- name: Run unit tests
|
||||
run: npm run test -w @documenso/lib -w @documenso/trpc
|
||||
|
||||
build_docker:
|
||||
name: Build Docker Image
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"background-jobs",
|
||||
"signing-certificate",
|
||||
"telemetry",
|
||||
"two-factor-enforcement",
|
||||
"organisation-limits",
|
||||
"advanced"
|
||||
]
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
---
|
||||
title: Two-Factor Enforcement
|
||||
description: Require two-factor authentication instance-wide or per organisation, with grace periods and important limitations.
|
||||
---
|
||||
|
||||
import { Callout } from 'fumadocs-ui/components/callout';
|
||||
|
||||
## Overview
|
||||
|
||||
Documenso can require users to enable two-factor authentication (2FA) at two levels:
|
||||
|
||||
- **Instance-wide enforcement** — configured by an instance administrator under **Admin → Site Settings**. Requires a Documenso license that includes the feature. Once a user's grace period expires, they are redirected to a forced enrolment page before they can continue using the app.
|
||||
- **Per-organisation enforcement** — available to everyone, configured by organisation admins in the organisation settings. Once a member's grace period expires, only access to that organisation (and its teams) is blocked; the rest of the app stays usable.
|
||||
|
||||
A user satisfies enforcement when they have 2FA enabled **and** their current session has passed a second factor (a TOTP/backup-code challenge, a user-verified passkey sign-in, or enabling 2FA during the session). Sessions created before the user enabled 2FA must sign out and back in to verify.
|
||||
|
||||
## Grace Periods
|
||||
|
||||
Both levels support a grace period of 0–365 days:
|
||||
|
||||
- **Instance**: the window starts at the later of the user's grace start (typically account creation, restarted by an admin 2FA reset) and the moment enforcement was enabled.
|
||||
- **Organisation**: the window starts at the latest of joining the organisation, the moment the organisation enabled enforcement, and the user's grace start.
|
||||
|
||||
A grace period of **0 days** enforces immediately: for the instance policy, users are forced to enrol right after signing up or signing in; for the organisation policy, members are blocked from the organisation until they enrol.
|
||||
|
||||
**Joining is never blocked; access is.** Invitations and SSO sign-ins always succeed — the grace window starts at join. Members who never comply still occupy a seat and count towards member limits; organisation admins can see per-member 2FA compliance in the members list.
|
||||
|
||||
Reducing an active grace period requires an explicit acknowledgement in the settings UI, since it can immediately block users who have not yet enrolled.
|
||||
|
||||
Enabling enforcement requires the acting administrator to already satisfy the policy themselves (2FA enabled and verified on their current session). This prevents administrators from locking themselves out with a 0-day grace period.
|
||||
|
||||
## Known Limitation: API Tokens Are Exempt
|
||||
|
||||
<Callout type="warn">
|
||||
Enforcement applies to interactive (session-based) access only. **API tokens minted before a
|
||||
user's deadline keep working after it.** A blocked user cannot mint new tokens, but existing
|
||||
tokens are not revoked by enforcement. If you need to cut off a non-compliant user's API access,
|
||||
revoke their tokens explicitly.
|
||||
</Callout>
|
||||
|
||||
## Licensing
|
||||
|
||||
Instance-wide enforcement is license-gated:
|
||||
|
||||
- Without the license, the instance-wide section in Admin → Site Settings is visible but disabled.
|
||||
- If enforcement was configured while licensed and the license later lapses, the stored configuration becomes **inactive** (nothing is enforced) and the only permitted change is disabling it.
|
||||
|
||||
Per-organisation enforcement does not require a license. When instance-wide enforcement is active, it takes precedence over organisation policies.
|
||||
|
||||
---
|
||||
|
||||
## See Also
|
||||
|
||||
- [License](/docs/self-hosting/configuration/license) - Configuring your Documenso license
|
||||
@@ -56,7 +56,7 @@ export const AdminUserResetTwoFactorDialog = ({ className, user }: AdminUserRese
|
||||
.with(AppErrorCode.NOT_FOUND, () => msg`User not found.`)
|
||||
.with(
|
||||
AppErrorCode.UNAUTHORIZED,
|
||||
() => msg`You are not authorized to reset two factor authentcation for this user.`,
|
||||
() => msg`You are not authorized to reset two factor authentication for this user.`,
|
||||
)
|
||||
.otherwise(() => msg`An error occurred while resetting two factor authentication for the user.`);
|
||||
|
||||
@@ -85,7 +85,8 @@ export const AdminUserResetTwoFactorDialog = ({ className, user }: AdminUserRese
|
||||
<AlertDescription className="mr-2">
|
||||
<Trans>
|
||||
Reset the users two factor authentication. This action is irreversible and will disable two factor
|
||||
authentication for the user.
|
||||
authentication for the user. Their two-factor enforcement grace period will restart from the moment of the
|
||||
reset.
|
||||
</Trans>
|
||||
</AlertDescription>
|
||||
</div>
|
||||
@@ -108,7 +109,8 @@ export const AdminUserResetTwoFactorDialog = ({ className, user }: AdminUserRese
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription className="selection:bg-red-100">
|
||||
<Trans>
|
||||
This action is irreversible. Please ensure you have informed the user before proceeding.
|
||||
This action is irreversible. Please ensure you have informed the user before proceeding. Any
|
||||
two-factor enforcement grace period for this user will restart from the moment of the reset.
|
||||
</Trans>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { authClient } from '@documenso/auth/client';
|
||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||
import { AppError } from '@documenso/lib/errors/app-error';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Dialog,
|
||||
@@ -68,15 +69,23 @@ export const DisableAuthenticatorAppDialog = () => {
|
||||
|
||||
const { isSubmitting: isDisable2FASubmitting } = disable2FAForm.formState;
|
||||
|
||||
// Todo: (2FA enforcement, step 5) Once org enforcement state is available
|
||||
// client-side, warn BEFORE disabling that org/team access will block at the
|
||||
// org 2FA deadline. Until then the warning is shown after the fact based on
|
||||
// the server response.
|
||||
const onDisable2FAFormSubmit = async ({ totpCode, backupCode }: TDisable2FAForm) => {
|
||||
try {
|
||||
await authClient.twoFactor.disable({ totpCode, backupCode });
|
||||
const { orgEnforcementApplies } = await authClient.twoFactor.disable({ totpCode, backupCode });
|
||||
|
||||
toast({
|
||||
title: _(msg`Two-factor authentication disabled`),
|
||||
description: _(
|
||||
msg`Two-factor authentication has been disabled for your account. You will no longer be required to enter a code from your authenticator app when signing in.`,
|
||||
),
|
||||
description: orgEnforcementApplies
|
||||
? _(
|
||||
msg`Two-factor authentication has been disabled for your account. One of your organisations requires two-factor authentication: access to it will be blocked at its deadline until you re-enable 2FA.`,
|
||||
)
|
||||
: _(
|
||||
msg`Two-factor authentication has been disabled for your account. You will no longer be required to enter a code from your authenticator app when signing in.`,
|
||||
),
|
||||
});
|
||||
|
||||
flushSync(() => {
|
||||
@@ -84,7 +93,19 @@ export const DisableAuthenticatorAppDialog = () => {
|
||||
});
|
||||
|
||||
await refreshSession();
|
||||
} catch (_err) {
|
||||
} catch (err) {
|
||||
const error = AppError.parseError(err);
|
||||
|
||||
if (error.code === 'TWO_FACTOR_DISABLE_FORBIDDEN') {
|
||||
toast({
|
||||
title: _(msg`Unable to disable two-factor authentication`),
|
||||
description: _(msg`Two-factor authentication is required by this instance and cannot be disabled.`),
|
||||
variant: 'destructive',
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
toast({
|
||||
title: _(msg`Unable to disable two-factor authentication`),
|
||||
description: _(
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { authClient } from '@documenso/auth/client';
|
||||
import { downloadFile } from '@documenso/lib/client-only/download-file';
|
||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||
import { AppError } from '@documenso/lib/errors/app-error';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Dialog,
|
||||
@@ -69,11 +70,18 @@ export const EnableAuthenticatorAppDialog = ({ onSuccess }: EnableAuthenticatorA
|
||||
|
||||
setSetup2FAData(data);
|
||||
} catch (err) {
|
||||
const error = AppError.parseError(err);
|
||||
|
||||
toast({
|
||||
title: _(msg`Unable to setup two-factor authentication`),
|
||||
description: _(
|
||||
msg`We were unable to setup two-factor authentication for your account. Please ensure that you have entered your code correctly and try again.`,
|
||||
),
|
||||
description:
|
||||
error.code === 'TWO_FACTOR_ALREADY_ENABLED'
|
||||
? _(
|
||||
msg`Two-factor authentication is already enabled for your account. Disable it before setting it up again.`,
|
||||
)
|
||||
: _(
|
||||
msg`We were unable to setup two-factor authentication for your account. Please ensure that you have entered your code correctly and try again.`,
|
||||
),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { isTwoFactorGracePeriodReduction } from '@documenso/lib/utils/two-factor';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Checkbox } from '@documenso/ui/primitives/checkbox';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@documenso/ui/primitives/form/form';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { Switch } from '@documenso/ui/primitives/switch';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { Loader } from 'lucide-react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
const ZTwoFactorEnforcementFormSchema = z.object({
|
||||
twoFactorRequired: z.boolean(),
|
||||
twoFactorGracePeriodDays: z.coerce.number().int().min(0).max(365),
|
||||
acknowledgeGracePeriodReduction: z.boolean(),
|
||||
});
|
||||
|
||||
type TTwoFactorEnforcementFormSchema = z.infer<typeof ZTwoFactorEnforcementFormSchema>;
|
||||
|
||||
/**
|
||||
* Organisation 2FA enforcement settings (require toggle + grace period).
|
||||
*
|
||||
* Rendered only for MANAGE_ORGANISATION_SECURITY holders (ADMIN). When
|
||||
* instance-wide enforcement is active the fields are shown disabled — not
|
||||
* hidden — with a banner explaining that the instance policy takes
|
||||
* precedence, so a configured organisation policy stays visible instead of
|
||||
* resurfacing already-expired later.
|
||||
*/
|
||||
export const OrganisationTwoFactorEnforcementForm = () => {
|
||||
const { _, i18n } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
const organisation = useCurrentOrganisation();
|
||||
const { twoFactorEnforcement: instanceTwoFactorEnforcement } = useSession();
|
||||
|
||||
const isInstanceEnforcementActive = instanceTwoFactorEnforcement.required;
|
||||
|
||||
const { data: organisationWithSettings, isLoading } = trpc.organisation.get.useQuery({
|
||||
organisationReference: organisation.url,
|
||||
});
|
||||
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
const { mutateAsync: updateOrganisationSettings } = trpc.organisation.settings.update.useMutation();
|
||||
|
||||
const settings = organisationWithSettings?.organisationGlobalSettings;
|
||||
|
||||
const form = useForm<TTwoFactorEnforcementFormSchema>({
|
||||
values: {
|
||||
twoFactorRequired: settings?.twoFactorRequired ?? false,
|
||||
twoFactorGracePeriodDays: settings?.twoFactorGracePeriodDays ?? 7,
|
||||
acknowledgeGracePeriodReduction: false,
|
||||
},
|
||||
resolver: zodResolver(ZTwoFactorEnforcementFormSchema),
|
||||
});
|
||||
|
||||
const watchedValues = form.watch();
|
||||
|
||||
// Client-side mirror of the server's grace-reduction detection so we can
|
||||
// surface the acknowledgement checkbox before submitting.
|
||||
const isGraceReduction =
|
||||
settings !== undefined &&
|
||||
isTwoFactorGracePeriodReduction({
|
||||
previous: settings.twoFactorRequired
|
||||
? {
|
||||
anchors: [settings.twoFactorEnforcedFrom],
|
||||
gracePeriodDays: settings.twoFactorGracePeriodDays,
|
||||
}
|
||||
: null,
|
||||
next: watchedValues.twoFactorRequired
|
||||
? {
|
||||
anchors: [settings.twoFactorRequired ? settings.twoFactorEnforcedFrom : new Date()],
|
||||
gracePeriodDays: watchedValues.twoFactorGracePeriodDays,
|
||||
}
|
||||
: null,
|
||||
now: new Date(),
|
||||
});
|
||||
|
||||
const onSubmit = async (data: TTwoFactorEnforcementFormSchema) => {
|
||||
try {
|
||||
await updateOrganisationSettings({
|
||||
organisationId: organisation.id,
|
||||
acknowledgeGracePeriodReduction: data.acknowledgeGracePeriodReduction,
|
||||
data: {
|
||||
twoFactorRequired: data.twoFactorRequired,
|
||||
twoFactorGracePeriodDays: data.twoFactorGracePeriodDays,
|
||||
},
|
||||
});
|
||||
|
||||
await utils.organisation.get.invalidate();
|
||||
|
||||
toast({
|
||||
title: _(msg`Two-factor enforcement settings updated`),
|
||||
});
|
||||
|
||||
form.setValue('acknowledgeGracePeriodReduction', false);
|
||||
} catch (err) {
|
||||
const error = AppError.parseError(err);
|
||||
|
||||
if (error.code === AppErrorCode.TWO_FACTOR_REQUIRED) {
|
||||
toast({
|
||||
title: _(msg`Two-factor authentication required`),
|
||||
description: _(
|
||||
msg`You must have two-factor authentication enabled and verified on this session before requiring it for the organisation.`,
|
||||
),
|
||||
variant: 'destructive',
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
toast({
|
||||
title: _(msg`Something went wrong`),
|
||||
description: _(msg`We were unable to update the two-factor enforcement settings. Please try again.`),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading || !settings) {
|
||||
return (
|
||||
<div className="flex justify-center rounded-lg border py-16">
|
||||
<Loader className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<fieldset
|
||||
disabled={form.formState.isSubmitting || isInstanceEnforcementActive}
|
||||
className="flex flex-col gap-y-4"
|
||||
>
|
||||
{isInstanceEnforcementActive && (
|
||||
<Alert variant="neutral">
|
||||
<AlertTitle>
|
||||
<Trans>Instance policy takes precedence</Trans>
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
<Trans>
|
||||
Two-factor authentication is enforced instance-wide by your administrator, so the organisation policy
|
||||
below is not editable while the instance policy is active.
|
||||
</Trans>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="twoFactorRequired"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
|
||||
<div className="space-y-0.5 pr-4">
|
||||
<FormLabel>
|
||||
<Trans>Require two-factor authentication</Trans>
|
||||
</FormLabel>
|
||||
<FormDescription>
|
||||
<Trans>
|
||||
Members must enable two-factor authentication to access this organisation. Joining is never
|
||||
blocked — the grace period starts when a member joins.
|
||||
</Trans>
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch checked={field.value} onCheckedChange={field.onChange} />
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="twoFactorGracePeriodDays"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Grace period (days)</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="number" min={0} max={365} {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
<Trans>
|
||||
Number of days a member has to enable two-factor authentication after joining. 0 blocks organisation
|
||||
access immediately until they enrol.
|
||||
</Trans>
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{settings.twoFactorRequired && settings.twoFactorEnforcedFrom && (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
<Trans>
|
||||
Enforcement has been active since {i18n.date(settings.twoFactorEnforcedFrom, { dateStyle: 'long' })}.
|
||||
</Trans>
|
||||
</p>
|
||||
)}
|
||||
|
||||
<Alert variant="neutral">
|
||||
<AlertDescription>
|
||||
<ul className="list-disc space-y-1 pl-4">
|
||||
<li>
|
||||
<Trans>
|
||||
API tokens are exempt: tokens minted before a member's deadline keep working after it. Blocked
|
||||
members cannot mint new tokens.
|
||||
</Trans>
|
||||
</li>
|
||||
<li>
|
||||
<Trans>
|
||||
Members who have not yet complied still occupy a seat and count towards your member limit.
|
||||
</Trans>
|
||||
</li>
|
||||
</ul>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
{isGraceReduction && (
|
||||
<Alert variant="warning">
|
||||
<AlertTitle>
|
||||
<Trans>This change reduces an active grace period</Trans>
|
||||
</AlertTitle>
|
||||
<AlertDescription className="flex flex-col gap-y-3">
|
||||
<Trans>
|
||||
Members who have not yet enabled two-factor authentication will have less time to comply — possibly
|
||||
none, which blocks their organisation access immediately.
|
||||
</Trans>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="acknowledgeGracePeriodReduction"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center gap-x-2 space-y-0">
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
checked={field.value}
|
||||
onCheckedChange={(checked) => field.onChange(checked === true)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormLabel className="font-normal">
|
||||
<Trans>I understand that this reduces the remaining grace period for members</Trans>
|
||||
</FormLabel>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="submit"
|
||||
loading={form.formState.isSubmitting}
|
||||
disabled={isGraceReduction && !watchedValues.acknowledgeGracePeriodReduction}
|
||||
>
|
||||
<Trans>Update</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
</fieldset>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,332 @@
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { isTwoFactorGracePeriodReduction } from '@documenso/lib/utils/two-factor';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Checkbox } from '@documenso/ui/primitives/checkbox';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@documenso/ui/primitives/form/form';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { Switch } from '@documenso/ui/primitives/switch';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { LoaderIcon } from 'lucide-react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
const ZTwoFactorEnforcementFormSchema = z.object({
|
||||
enabled: z.boolean(),
|
||||
gracePeriodDays: z.coerce.number().int().min(0).max(365),
|
||||
acknowledgeGracePeriodReduction: z.boolean(),
|
||||
});
|
||||
|
||||
type TTwoFactorEnforcementFormSchema = z.infer<typeof ZTwoFactorEnforcementFormSchema>;
|
||||
|
||||
/**
|
||||
* Instance-wide 2FA enforcement settings for the admin site-settings page.
|
||||
*
|
||||
* License-gated states:
|
||||
*
|
||||
* - Licensed: full form.
|
||||
* - Unlicensed + unconfigured: section visible but disabled with a "requires
|
||||
* license" note.
|
||||
* - Unlicensed + configured ("configured but inactive", e.g. license lapsed):
|
||||
* stored values shown read-only with a disable-only affordance — the only
|
||||
* permitted unlicensed update is turning the stored policy off.
|
||||
*/
|
||||
export const AdminTwoFactorEnforcementSection = () => {
|
||||
const { _, i18n } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
const { data: enforcementConfig, isLoading } = trpc.admin.getTwoFactorEnforcement.useQuery();
|
||||
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
const { mutateAsync: updateTwoFactorEnforcement, isPending: isUpdatePending } =
|
||||
trpc.admin.updateTwoFactorEnforcement.useMutation();
|
||||
|
||||
const form = useForm<TTwoFactorEnforcementFormSchema>({
|
||||
values: {
|
||||
enabled: enforcementConfig?.enabled ?? false,
|
||||
gracePeriodDays: enforcementConfig?.gracePeriodDays ?? 7,
|
||||
acknowledgeGracePeriodReduction: false,
|
||||
},
|
||||
resolver: zodResolver(ZTwoFactorEnforcementFormSchema),
|
||||
});
|
||||
|
||||
const watchedValues = form.watch();
|
||||
|
||||
const isLicensed = enforcementConfig?.isLicensed ?? false;
|
||||
const isConfiguredButInactive = !isLicensed && (enforcementConfig?.enabled ?? false);
|
||||
|
||||
// Client-side mirror of the server's grace-reduction detection so we can
|
||||
// surface the acknowledgement checkbox before submitting. The server resets
|
||||
// `enforcedFrom` to now on an off→on transition, hence the `new Date()`
|
||||
// anchor when the stored policy is currently disabled.
|
||||
const isGraceReduction =
|
||||
enforcementConfig !== undefined &&
|
||||
isTwoFactorGracePeriodReduction({
|
||||
previous: enforcementConfig.enabled
|
||||
? {
|
||||
anchors: [enforcementConfig.enforcedFrom ? new Date(enforcementConfig.enforcedFrom) : null],
|
||||
gracePeriodDays: enforcementConfig.gracePeriodDays,
|
||||
}
|
||||
: null,
|
||||
next: watchedValues.enabled
|
||||
? {
|
||||
anchors: [
|
||||
enforcementConfig.enabled && enforcementConfig.enforcedFrom
|
||||
? new Date(enforcementConfig.enforcedFrom)
|
||||
: new Date(),
|
||||
],
|
||||
gracePeriodDays: watchedValues.gracePeriodDays,
|
||||
}
|
||||
: null,
|
||||
now: new Date(),
|
||||
});
|
||||
|
||||
const onUpdate = async (data: {
|
||||
enabled: boolean;
|
||||
gracePeriodDays: number;
|
||||
acknowledgeGracePeriodReduction?: boolean;
|
||||
}) => {
|
||||
try {
|
||||
await updateTwoFactorEnforcement(data);
|
||||
|
||||
await utils.admin.getTwoFactorEnforcement.invalidate();
|
||||
|
||||
toast({
|
||||
title: _(msg`Two-factor enforcement settings updated`),
|
||||
});
|
||||
|
||||
form.setValue('acknowledgeGracePeriodReduction', false);
|
||||
} catch (err) {
|
||||
const error = AppError.parseError(err);
|
||||
|
||||
if (error.code === AppErrorCode.TWO_FACTOR_REQUIRED) {
|
||||
toast({
|
||||
title: _(msg`Two-factor authentication required`),
|
||||
description: _(
|
||||
msg`Enable two-factor authentication on your own account and verify it on this session before requiring it for the instance.`,
|
||||
),
|
||||
variant: 'destructive',
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (error.code === AppErrorCode.FORBIDDEN) {
|
||||
toast({
|
||||
title: _(msg`License required`),
|
||||
description: _(
|
||||
msg`Your license does not include instance-wide two-factor enforcement. Only disabling the stored configuration is permitted.`,
|
||||
),
|
||||
variant: 'destructive',
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
toast({
|
||||
title: _(msg`Something went wrong`),
|
||||
description: _(msg`We were unable to update the two-factor enforcement settings. Please try again.`),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const onSubmit = async (data: TTwoFactorEnforcementFormSchema) => {
|
||||
await onUpdate(data);
|
||||
};
|
||||
|
||||
// Disable-only affordance for the "configured but inactive" state: submits
|
||||
// an enabled→disabled transition with the stored values unchanged, which is
|
||||
// the only unlicensed update the server accepts.
|
||||
const onDisableOnly = async () => {
|
||||
if (!enforcementConfig) {
|
||||
return;
|
||||
}
|
||||
|
||||
await onUpdate({
|
||||
enabled: false,
|
||||
gracePeriodDays: enforcementConfig.gracePeriodDays,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 className="font-semibold">
|
||||
<Trans>Instance Two-Factor Enforcement</Trans>
|
||||
</h2>
|
||||
<p className="mt-2 text-muted-foreground text-sm">
|
||||
<Trans>
|
||||
Require every user on this instance, including administrators, to enable two-factor authentication within a
|
||||
grace period.
|
||||
</Trans>
|
||||
</p>
|
||||
|
||||
{isLoading || !enforcementConfig ? (
|
||||
<div className="mt-4 flex justify-center rounded-lg border py-16">
|
||||
<LoaderIcon className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<fieldset disabled={form.formState.isSubmitting || !isLicensed} className="mt-4 flex flex-col gap-y-4">
|
||||
{!isLicensed && !isConfiguredButInactive && (
|
||||
<Alert variant="neutral">
|
||||
<AlertTitle>
|
||||
<Trans>Requires a license</Trans>
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
<Trans>
|
||||
Instance-wide two-factor enforcement requires a Documenso license that includes this feature.
|
||||
</Trans>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{isConfiguredButInactive && (
|
||||
<Alert variant="warning">
|
||||
<AlertTitle>
|
||||
<Trans>Configured but inactive</Trans>
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
<Trans>
|
||||
Two-factor enforcement is configured but your current license does not include this feature, so it
|
||||
is not being enforced. You can disable the stored configuration below; changing it requires a
|
||||
license.
|
||||
</Trans>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="enabled"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
|
||||
<div className="space-y-0.5 pr-4">
|
||||
<FormLabel>
|
||||
<Trans>Require two-factor authentication</Trans>
|
||||
</FormLabel>
|
||||
<FormDescription>
|
||||
<Trans>
|
||||
Users who have not enabled two-factor authentication by their deadline are redirected to a
|
||||
forced enrolment page before they can continue.
|
||||
</Trans>
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch checked={field.value} onCheckedChange={field.onChange} />
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="gracePeriodDays"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Grace period (days)</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="number" min={0} max={365} {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
<Trans>
|
||||
Number of days a user has to enable two-factor authentication. 0 forces enrolment immediately
|
||||
after signing up or signing in.
|
||||
</Trans>
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{enforcementConfig.isActive && enforcementConfig.enforcedFrom && (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
<Trans>
|
||||
Enforcement has been active since{' '}
|
||||
{i18n.date(new Date(enforcementConfig.enforcedFrom), { dateStyle: 'long' })}.
|
||||
</Trans>
|
||||
</p>
|
||||
)}
|
||||
|
||||
<Alert variant="neutral">
|
||||
<AlertDescription>
|
||||
<Trans>
|
||||
API tokens are exempt: tokens minted before a user's deadline keep working after it. Blocked users
|
||||
cannot mint new tokens.
|
||||
</Trans>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
{isGraceReduction && (
|
||||
<Alert variant="warning">
|
||||
<AlertTitle>
|
||||
<Trans>This change reduces an active grace period</Trans>
|
||||
</AlertTitle>
|
||||
<AlertDescription className="flex flex-col gap-y-3">
|
||||
<Trans>
|
||||
Users who have not yet enabled two-factor authentication will have less time to comply — possibly
|
||||
none, which blocks their access immediately.
|
||||
</Trans>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="acknowledgeGracePeriodReduction"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center gap-x-2 space-y-0">
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
checked={field.value}
|
||||
onCheckedChange={(checked) => field.onChange(checked === true)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormLabel className="font-normal">
|
||||
<Trans>I understand that this reduces the remaining grace period for users</Trans>
|
||||
</FormLabel>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="submit"
|
||||
loading={form.formState.isSubmitting}
|
||||
disabled={isGraceReduction && !watchedValues.acknowledgeGracePeriodReduction}
|
||||
>
|
||||
<Trans>Update</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
{isConfiguredButInactive && (
|
||||
<div className="mt-4 flex justify-end">
|
||||
<Button type="button" variant="destructive" loading={isUpdatePending} onClick={onDisableOnly}>
|
||||
<Trans>Disable enforcement</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</Form>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,144 @@
|
||||
import { useOptionalCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||
import { useOptionalSession } from '@documenso/lib/client-only/providers/session';
|
||||
import type { TTwoFactorEnforcementStatus } from '@documenso/lib/utils/two-factor';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { AlertTriangleIcon, XIcon } from 'lucide-react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Link, useLocation } from 'react-router';
|
||||
|
||||
type GraceBannerCandidate = {
|
||||
/**
|
||||
* Dismissal scope: `instance` or `org:<organisationId>`.
|
||||
*/
|
||||
scope: string;
|
||||
deadline: Date;
|
||||
isSessionUnverified: boolean;
|
||||
};
|
||||
|
||||
const buildDismissalKey = (userId: number, candidate: GraceBannerCandidate) =>
|
||||
`2fa-grace-banner:${userId}:${candidate.scope}:${candidate.deadline.getTime()}`;
|
||||
|
||||
const toCandidate = (
|
||||
status: TTwoFactorEnforcementStatus,
|
||||
scope: string,
|
||||
isSessionUnverified: boolean,
|
||||
): GraceBannerCandidate | null => {
|
||||
// Banner territory is the grace window only: required, not yet satisfied,
|
||||
// not yet expired. Expiry is handled by the org 403 screen (and, for
|
||||
// instance enforcement, the onboarding redirect).
|
||||
if (!status.required || status.isSatisfied || status.isDeadlineExpired) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
scope,
|
||||
deadline: status.deadline,
|
||||
isSessionUnverified,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Shared grace-period banner for 2FA enforcement.
|
||||
*
|
||||
* Shows the NEAREST applicable deadline between instance enforcement and the
|
||||
* current organisation's enforcement. Dismissal is stored in `sessionStorage`
|
||||
* keyed by userId + scope + deadline, so a changed deadline re-shows the
|
||||
* banner.
|
||||
*/
|
||||
export const TwoFactorGraceBanner = () => {
|
||||
const { i18n } = useLingui();
|
||||
|
||||
const { sessionData } = useOptionalSession();
|
||||
const currentOrganisation = useOptionalCurrentOrganisation();
|
||||
|
||||
const location = useLocation();
|
||||
|
||||
const [dismissedKeys, setDismissedKeys] = useState<string[]>([]);
|
||||
|
||||
const candidate = useMemo(() => {
|
||||
if (!sessionData) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isSessionUnverified = sessionData.user.twoFactorEnabled && !sessionData.session.twoFactorVerified;
|
||||
|
||||
const candidates = [
|
||||
toCandidate(sessionData.twoFactorEnforcement, 'instance', isSessionUnverified),
|
||||
currentOrganisation
|
||||
? toCandidate(currentOrganisation.twoFactorEnforcement, `org:${currentOrganisation.id}`, isSessionUnverified)
|
||||
: null,
|
||||
].filter((value): value is GraceBannerCandidate => value !== null);
|
||||
|
||||
if (candidates.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return candidates.reduce((nearest, current) =>
|
||||
current.deadline.getTime() < nearest.deadline.getTime() ? current : nearest,
|
||||
);
|
||||
}, [sessionData, currentOrganisation]);
|
||||
|
||||
if (!sessionData || !candidate) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const dismissalKey = buildDismissalKey(sessionData.user.id, candidate);
|
||||
|
||||
const isDismissed =
|
||||
dismissedKeys.includes(dismissalKey) ||
|
||||
(typeof window !== 'undefined' && window.sessionStorage.getItem(dismissalKey) === 'true');
|
||||
|
||||
if (isDismissed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const onDismiss = () => {
|
||||
try {
|
||||
window.sessionStorage.setItem(dismissalKey, 'true');
|
||||
} catch {
|
||||
// Storage may be unavailable (private browsing); fall back to state.
|
||||
}
|
||||
|
||||
setDismissedKeys((keys) => [...keys, dismissalKey]);
|
||||
};
|
||||
|
||||
const returnTo = encodeURIComponent(`${location.pathname}${location.search}`);
|
||||
|
||||
return (
|
||||
<div className="bg-yellow-200 dark:bg-yellow-400">
|
||||
<div className="mx-auto flex max-w-screen-xl items-center justify-between gap-x-4 px-4 py-2 font-medium text-sm text-yellow-900">
|
||||
<div className="flex items-center gap-x-2">
|
||||
<AlertTriangleIcon className="h-4 w-4 flex-shrink-0" />
|
||||
|
||||
<span>
|
||||
{candidate.isSessionUnverified ? (
|
||||
<Trans>
|
||||
Two-factor authentication is required from {i18n.date(candidate.deadline, { dateStyle: 'long' })}.
|
||||
Two-factor authentication is enabled for your account, but this session has not been verified with a
|
||||
second factor — sign out and log back in to verify this session.
|
||||
</Trans>
|
||||
) : (
|
||||
<Trans>
|
||||
Two-factor authentication is required from {i18n.date(candidate.deadline, { dateStyle: 'long' })}.{' '}
|
||||
<Link to={`/onboarding/2fa?returnTo=${returnTo}`} className="underline">
|
||||
Enable it now
|
||||
</Link>{' '}
|
||||
to keep access.
|
||||
</Trans>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="rounded p-1 hover:bg-yellow-300 dark:hover:bg-yellow-500"
|
||||
aria-label="Dismiss"
|
||||
onClick={onDismiss}
|
||||
>
|
||||
<XIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -6,6 +6,7 @@ import { isOrganisationRoleWithinUserHierarchy } from '@documenso/lib/utils/orga
|
||||
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
|
||||
import { Badge } from '@documenso/ui/primitives/badge';
|
||||
import type { DataTableColumnDef } from '@documenso/ui/primitives/data-table';
|
||||
import { DataTable } from '@documenso/ui/primitives/data-table';
|
||||
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
|
||||
@@ -100,6 +101,23 @@ export const OrganisationMembersDataTable = () => {
|
||||
header: _(msg`Groups`),
|
||||
cell: ({ row }) => row.original.groups.filter((group) => group.type === OrganisationGroupType.CUSTOM).length,
|
||||
},
|
||||
{
|
||||
// Per-member 2FA compliance indicator: enrolment is the durable half
|
||||
// of the satisfaction rule, so org admins can see who has and hasn't
|
||||
// enrolled (non-compliant members still consume seats).
|
||||
header: _(msg`2FA`),
|
||||
accessorKey: 'twoFactorEnabled',
|
||||
cell: ({ row }) =>
|
||||
row.original.twoFactorEnabled ? (
|
||||
<Badge variant="default">
|
||||
<Trans>Enrolled</Trans>
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="neutral">
|
||||
<Trans>Not enrolled</Trans>
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: _(msg`Actions`),
|
||||
cell: ({ row }) => (
|
||||
|
||||
+14
-1
@@ -3,8 +3,10 @@ import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
||||
import { SessionProvider } from '@documenso/lib/client-only/providers/session';
|
||||
import { getBasePath } from '@documenso/lib/constants/app';
|
||||
import { APP_I18N_OPTIONS, type SupportedLanguageCodes } from '@documenso/lib/constants/i18n';
|
||||
import { getTwoFactorEnforcementStatus } from '@documenso/lib/server-only/2fa/get-two-factor-enforcement-status';
|
||||
import { createPublicEnv } from '@documenso/lib/utils/env';
|
||||
import { extractLocaleData } from '@documenso/lib/utils/i18n';
|
||||
import type { TTwoFactorEnforcementStatus } from '@documenso/lib/utils/two-factor';
|
||||
import { TrpcProvider } from '@documenso/trpc/react';
|
||||
import { getOrganisationSession } from '@documenso/trpc/server/organisation-router/get-organisation-session';
|
||||
import { Toaster } from '@documenso/ui/primitives/toaster';
|
||||
@@ -63,9 +65,19 @@ export async function loader({ context, request }: Route.LoaderArgs) {
|
||||
const disableAnimations = cookieHeader.includes('__disable_animations=true');
|
||||
|
||||
let organisations = null;
|
||||
let twoFactorEnforcement: TTwoFactorEnforcementStatus = { required: false };
|
||||
|
||||
if (session.isAuthenticated) {
|
||||
organisations = await getOrganisationSession({ userId: session.user.id });
|
||||
[organisations, twoFactorEnforcement] = await Promise.all([
|
||||
getOrganisationSession({
|
||||
userId: session.user.id,
|
||||
user: session.user,
|
||||
session: session.session,
|
||||
}),
|
||||
// Instance 2FA enforcement status is part of the session payload so
|
||||
// layouts can derive banners/redirects client-side without new queries.
|
||||
getTwoFactorEnforcementStatus({ user: session.user, session: session.session }),
|
||||
]);
|
||||
}
|
||||
|
||||
return data(
|
||||
@@ -83,6 +95,7 @@ export async function loader({ context, request }: Route.LoaderArgs) {
|
||||
user: session.user,
|
||||
session: session.session,
|
||||
organisations: organisations || [],
|
||||
twoFactorEnforcement,
|
||||
}
|
||||
: null,
|
||||
publicEnv: createPublicEnv(),
|
||||
|
||||
@@ -1,31 +1,44 @@
|
||||
import { authClient } from '@documenso/auth/client';
|
||||
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
|
||||
import { useChildRouteFlags } from '@documenso/lib/client-only/hooks/use-child-route-flags';
|
||||
import { OrganisationProvider } from '@documenso/lib/client-only/providers/organisation';
|
||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||
import { getTwoFactorEnforcementStatus } from '@documenso/lib/server-only/2fa/get-two-factor-enforcement-status';
|
||||
import { getSiteSettings } from '@documenso/lib/server-only/site-settings/get-site-settings';
|
||||
import { SITE_SETTINGS_BANNER_ID } from '@documenso/lib/server-only/site-settings/schemas/banner';
|
||||
import { isValidReturnTo, normalizeReturnTo } from '@documenso/lib/utils/is-valid-return-to';
|
||||
import { calculateTwoFactorDeadlineTimerDelay } from '@documenso/lib/utils/two-factor';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { Link, Outlet, redirect } from 'react-router';
|
||||
import { useEffect } from 'react';
|
||||
import { Link, Outlet, redirect, useLocation, useNavigate } from 'react-router';
|
||||
|
||||
import { AppBanner } from '~/components/general/app-banner';
|
||||
import { Header } from '~/components/general/app-header';
|
||||
import { GenericErrorLayout } from '~/components/general/generic-error-layout';
|
||||
import { OrganisationBillingBanner } from '~/components/general/organisations/organisation-billing-banner';
|
||||
import { OrganisationQuotaBanner } from '~/components/general/organisations/organisation-quota-banner';
|
||||
import { TwoFactorGraceBanner } from '~/components/general/two-factor-grace-banner';
|
||||
import { VerifyEmailBanner } from '~/components/general/verify-email-banner';
|
||||
import { TeamProvider } from '~/providers/team';
|
||||
|
||||
import type { Route } from './+types/_layout';
|
||||
|
||||
/**
|
||||
* Don't revalidate (run the loader on sequential navigations)
|
||||
*
|
||||
* Update values via providers.
|
||||
* Builds the enrolment redirect for an instance-blocked user, carrying the
|
||||
* current path so they land back where they were after enrolling.
|
||||
*/
|
||||
export const shouldRevalidate = () => false;
|
||||
const buildTwoFactorOnboardingPath = (currentPath: string) => {
|
||||
const returnTo = (isValidReturnTo(currentPath) && normalizeReturnTo(currentPath)) || '/';
|
||||
|
||||
return `/onboarding/2fa?returnTo=${encodeURIComponent(returnTo)}`;
|
||||
};
|
||||
|
||||
// Note: no `shouldRevalidate` suppression on this layout (the root layout
|
||||
// keeps its own) — the loader must rerun on navigations so the instance
|
||||
// enforcement redirect below is re-evaluated server-side.
|
||||
|
||||
export async function loader({ request }: Route.LoaderArgs) {
|
||||
const [session, banner] = await Promise.all([
|
||||
@@ -37,6 +50,22 @@ export async function loader({ request }: Route.LoaderArgs) {
|
||||
throw redirect('/signin');
|
||||
}
|
||||
|
||||
// Instance-wide 2FA enforcement (UX chokepoint — the security boundary is
|
||||
// the tRPC/Hono asserts): a blocked user is redirected into forced
|
||||
// enrolment. `/onboarding/2fa` lives outside this layout, so the redirect
|
||||
// cannot loop. Never blocks login itself — signin/onboarding are outside
|
||||
// this layout too.
|
||||
const twoFactorEnforcement = await getTwoFactorEnforcementStatus({
|
||||
user: session.user,
|
||||
session: session.session,
|
||||
});
|
||||
|
||||
if (twoFactorEnforcement.required && twoFactorEnforcement.isBlocked) {
|
||||
const url = new URL(request.url);
|
||||
|
||||
throw redirect(buildTwoFactorOnboardingPath(`${url.pathname}${url.search}`));
|
||||
}
|
||||
|
||||
return {
|
||||
banner,
|
||||
};
|
||||
@@ -45,7 +74,51 @@ export async function loader({ request }: Route.LoaderArgs) {
|
||||
export default function Layout({ loaderData, params, matches }: Route.ComponentProps) {
|
||||
const { banner } = loaderData;
|
||||
|
||||
const { user, organisations } = useSession();
|
||||
const { user, session, organisations, twoFactorEnforcement } = useSession();
|
||||
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Client-side counterpart of the loader's instance enforcement redirect:
|
||||
// parent-layout loaders don't rerun on every child navigation, and a grace
|
||||
// deadline can pass while the app is open. The session provider refreshes
|
||||
// the enforcement status on navigation/focus; the timer covers a deadline
|
||||
// crossing while the tab sits idle.
|
||||
const isInstanceTwoFactorBlocked = twoFactorEnforcement.required && twoFactorEnforcement.isBlocked;
|
||||
|
||||
useEffect(() => {
|
||||
if (!twoFactorEnforcement.required || twoFactorEnforcement.isSatisfied) {
|
||||
return;
|
||||
}
|
||||
|
||||
const redirectToOnboarding = () => {
|
||||
void navigate(buildTwoFactorOnboardingPath(`${location.pathname}${location.search}`));
|
||||
};
|
||||
|
||||
if (twoFactorEnforcement.isBlocked) {
|
||||
redirectToOnboarding();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Within grace: fire at the deadline instant. A `null` delay means the
|
||||
// deadline is beyond `setTimeout` range (~24.8 days) — no timer needed,
|
||||
// the status is re-evaluated long before then.
|
||||
const timerDelay = calculateTwoFactorDeadlineTimerDelay({
|
||||
deadline: twoFactorEnforcement.deadline,
|
||||
now: new Date(),
|
||||
});
|
||||
|
||||
if (timerDelay === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const timeout = window.setTimeout(redirectToOnboarding, timerDelay);
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(timeout);
|
||||
};
|
||||
}, [twoFactorEnforcement, location.pathname, location.search, navigate]);
|
||||
|
||||
const { layoutMode } = useChildRouteFlags();
|
||||
|
||||
@@ -80,6 +153,65 @@ export default function Layout({ loaderData, params, matches }: Route.ComponentP
|
||||
match?.id === 'routes/_authenticated+/t.$teamUrl+/templates.$id.edit',
|
||||
);
|
||||
|
||||
// Per-organisation 2FA enforcement: when the current org/team context's
|
||||
// organisation blocks the user, render a 403 screen (NOT a redirect — the
|
||||
// rest of the app stays usable) linking to the enrolment page. Derived
|
||||
// client-side from the session provider's bootstrap payload, which stays
|
||||
// readable while blocked. This is UX only — the security boundary is the
|
||||
// tRPC/Hono asserts.
|
||||
const isCurrentOrganisationTwoFactorBlocked =
|
||||
Boolean(orgUrl || teamUrl) &&
|
||||
Boolean(currentOrganisation?.twoFactorEnforcement.required && currentOrganisation.twoFactorEnforcement.isBlocked);
|
||||
|
||||
// State (b): enrolled, but this session never passed a second factor —
|
||||
// enrolment would rightly refuse, so the remediation is a fresh sign-in.
|
||||
const requiresRelogin = user.twoFactorEnabled && !session.twoFactorVerified;
|
||||
|
||||
// Instance enforcement takes precedence over the org 403 below: render
|
||||
// nothing while the effect above navigates to forced enrolment.
|
||||
if (isInstanceTwoFactorBlocked) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isCurrentOrganisationTwoFactorBlocked) {
|
||||
const returnTo = encodeURIComponent(`${location.pathname}${location.search}`);
|
||||
|
||||
return (
|
||||
<GenericErrorLayout
|
||||
errorCode={403}
|
||||
errorCodeMap={{
|
||||
403: {
|
||||
heading: msg`Two-factor authentication required`,
|
||||
subHeading: msg`403 Forbidden`,
|
||||
message: requiresRelogin
|
||||
? msg`This organisation requires two-factor authentication. Two-factor authentication is enabled for your account, but this session has not been verified with a second factor. Sign out and log back in to verify this session.`
|
||||
: msg`This organisation requires two-factor authentication. Enable it for your account to regain access. The rest of your account remains available.`,
|
||||
},
|
||||
}}
|
||||
primaryButton={
|
||||
requiresRelogin ? (
|
||||
<Button onClick={() => void authClient.signOut()}>
|
||||
<Trans>Sign out</Trans>
|
||||
</Button>
|
||||
) : (
|
||||
<Button asChild>
|
||||
<Link to={`/onboarding/2fa?returnTo=${returnTo}`}>
|
||||
<Trans>Set up two-factor authentication</Trans>
|
||||
</Link>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
secondaryButton={
|
||||
<Button variant="ghost" asChild>
|
||||
<Link to="/">
|
||||
<Trans>Go home</Trans>
|
||||
</Link>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (orgNotFound || teamNotFound) {
|
||||
return (
|
||||
<GenericErrorLayout
|
||||
@@ -112,6 +244,8 @@ export default function Layout({ loaderData, params, matches }: Route.ComponentP
|
||||
<OrganisationProvider organisation={currentOrganisation}>
|
||||
<TeamProvider team={currentTeam || null}>
|
||||
<div className={cn({ 'md:flex md:h-dvh md:flex-col md:overflow-hidden': layoutMode === 'settings' })}>
|
||||
<TwoFactorGraceBanner />
|
||||
|
||||
<OrganisationBillingBanner />
|
||||
|
||||
<OrganisationQuotaBanner />
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useLingui } from '@lingui/react';
|
||||
|
||||
import { AdminEmailBlocklistSection } from '~/components/general/admin-email-blocklist-section';
|
||||
import { AdminSiteBannerSection } from '~/components/general/admin-site-banner-section';
|
||||
import { AdminTwoFactorEnforcementSection } from '~/components/general/admin-two-factor-enforcement-section';
|
||||
import { SettingsHeader } from '~/components/general/settings-header';
|
||||
import type { Route } from './+types/site-settings';
|
||||
|
||||
@@ -31,6 +32,8 @@ export default function AdminSiteSettingsPage({ loaderData }: Route.ComponentPro
|
||||
<AdminSiteBannerSection banner={banner} />
|
||||
|
||||
<AdminEmailBlocklistSection emailBlocklist={emailBlocklist} />
|
||||
|
||||
<AdminTwoFactorEnforcementSection />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import type { TGetUserResponse } from '@documenso/trpc/server/admin-router/get-user.types';
|
||||
import { ZUpdateUserRequestSchema } from '@documenso/trpc/server/admin-router/update-user.types';
|
||||
@@ -75,6 +76,11 @@ const AdminUserPage = ({ user }: { user: TGetUserResponse }) => {
|
||||
const { toast } = useToast();
|
||||
const { revalidate } = useRevalidator();
|
||||
|
||||
const { user: currentUser } = useSession();
|
||||
|
||||
// Self-reset is forbidden server-side; hide the affordance entirely.
|
||||
const canResetTwoFactor = user.twoFactorEnabled && user.id !== currentUser.id;
|
||||
|
||||
const roles = user.roles ?? [];
|
||||
|
||||
const { mutateAsync: updateUserMutation } = trpc.admin.user.update.useMutation();
|
||||
@@ -228,7 +234,7 @@ const AdminUserPage = ({ user }: { user: TGetUserResponse }) => {
|
||||
</Accordion>
|
||||
|
||||
<div className="mt-16 flex flex-col gap-4">
|
||||
{user && user.twoFactorEnabled && <AdminUserResetTwoFactorDialog user={user} />}
|
||||
{canResetTwoFactor && <AdminUserResetTwoFactorDialog user={user} />}
|
||||
{user && user.disabled && <AdminUserEnableDialog userToEnable={user} />}
|
||||
{user && !user.disabled && <AdminUserDisableDialog userToDisable={user} />}
|
||||
{user && <AdminUserDeleteDialog user={user} />}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Trans } from '@lingui/react/macro';
|
||||
|
||||
import { OrganisationDeleteDialog } from '~/components/dialogs/organisation-delete-dialog';
|
||||
import { AvatarImageForm } from '~/components/forms/avatar-image';
|
||||
import { OrganisationTwoFactorEnforcementForm } from '~/components/forms/organisation-two-factor-enforcement-form';
|
||||
import { OrganisationUpdateForm } from '~/components/forms/organisation-update-form';
|
||||
import { SettingsHeader } from '~/components/general/settings-header';
|
||||
import { appMetaTags } from '~/utils/meta';
|
||||
@@ -29,6 +30,19 @@ export default function OrganisationSettingsGeneral() {
|
||||
<OrganisationUpdateForm />
|
||||
</div>
|
||||
|
||||
{canExecuteOrganisationAction('MANAGE_ORGANISATION_SECURITY', organisation.currentOrganisationRole) && (
|
||||
<>
|
||||
<hr className="my-6" />
|
||||
|
||||
<SettingsHeader
|
||||
title={_(msg`Two-factor authentication`)}
|
||||
subtitle={_(msg`Require members of this organisation to use two-factor authentication.`)}
|
||||
/>
|
||||
|
||||
<OrganisationTwoFactorEnforcementForm />
|
||||
</>
|
||||
)}
|
||||
|
||||
{canExecuteOrganisationAction('DELETE_ORGANISATION', organisation.currentOrganisationRole) && (
|
||||
<Alert className="flex flex-col justify-between p-6 sm:flex-row sm:items-center" variant="neutral">
|
||||
<div className="mb-4 sm:mb-0">
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
import { authClient } from '@documenso/auth/client';
|
||||
import { AuthenticationErrorCode } from '@documenso/auth/server/lib/errors/error-codes';
|
||||
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
|
||||
import { AppError } from '@documenso/lib/errors/app-error';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@documenso/ui/primitives/form/form';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { PinInput, PinInputGroup, PinInputSlot } from '@documenso/ui/primitives/pin-input';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { Loader2Icon } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { Link, redirect, useNavigate } from 'react-router';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { appMetaTags } from '~/utils/meta';
|
||||
|
||||
import type { Route } from './+types/2fa-challenge';
|
||||
|
||||
export function meta() {
|
||||
return appMetaTags(msg`Two-Factor Authentication`);
|
||||
}
|
||||
|
||||
export async function loader({ request }: Route.LoaderArgs) {
|
||||
const { isAuthenticated } = await getOptionalSession(request);
|
||||
|
||||
// A signed-in user has no pending challenge to complete (any successful
|
||||
// sign-in clears it server-side).
|
||||
if (isAuthenticated) {
|
||||
throw redirect('/');
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
const ZTwoFactorChallengeFormSchema = z.object({
|
||||
totpCode: z.string().trim().optional(),
|
||||
backupCode: z.string().trim().optional(),
|
||||
});
|
||||
|
||||
type TTwoFactorChallengeFormSchema = z.infer<typeof ZTwoFactorChallengeFormSchema>;
|
||||
|
||||
export default function TwoFactorChallenge() {
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [isValidatingChallenge, setIsValidatingChallenge] = useState(true);
|
||||
const [twoFactorAuthenticationMethod, setTwoFactorAuthenticationMethod] = useState<'totp' | 'backup'>('totp');
|
||||
|
||||
const form = useForm<TTwoFactorChallengeFormSchema>({
|
||||
values: {
|
||||
totpCode: '',
|
||||
backupCode: '',
|
||||
},
|
||||
resolver: zodResolver(ZTwoFactorChallengeFormSchema),
|
||||
});
|
||||
|
||||
const isSubmitting = form.formState.isSubmitting;
|
||||
|
||||
const onRedirectToSignIn = async () => {
|
||||
toast({
|
||||
title: _(msg`Sign in required`),
|
||||
description: _(msg`Your sign-in attempt has expired. Please sign in again.`),
|
||||
variant: 'destructive',
|
||||
});
|
||||
|
||||
await navigate('/signin');
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void authClient.twoFactor
|
||||
.getChallenge()
|
||||
.then(async ({ valid }) => {
|
||||
if (!valid) {
|
||||
await onRedirectToSignIn();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
setIsValidatingChallenge(false);
|
||||
})
|
||||
.catch(async () => onRedirectToSignIn());
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const onToggleTwoFactorAuthenticationMethodClick = () => {
|
||||
const method = twoFactorAuthenticationMethod === 'totp' ? 'backup' : 'totp';
|
||||
|
||||
if (method === 'totp') {
|
||||
form.setValue('backupCode', '');
|
||||
}
|
||||
|
||||
if (method === 'backup') {
|
||||
form.setValue('totpCode', '');
|
||||
}
|
||||
|
||||
setTwoFactorAuthenticationMethod(method);
|
||||
};
|
||||
|
||||
const onFormSubmit = async ({ totpCode, backupCode }: TTwoFactorChallengeFormSchema) => {
|
||||
try {
|
||||
// On success this navigates to the server-provided redirect path.
|
||||
await authClient.twoFactor.verifyChallenge(
|
||||
twoFactorAuthenticationMethod === 'totp' ? { totpCode } : { backupCode },
|
||||
);
|
||||
} catch (err) {
|
||||
const error = AppError.parseError(err);
|
||||
|
||||
if (error.code === AuthenticationErrorCode.TwoFactorChallengeExpired) {
|
||||
await onRedirectToSignIn();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (error.code === AuthenticationErrorCode.InvalidTwoFactorCode) {
|
||||
toast({
|
||||
title: _(msg`Unable to sign in`),
|
||||
description: _(msg`The two-factor authentication code provided is incorrect.`),
|
||||
variant: 'destructive',
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
toast({
|
||||
title: _(msg`Something went wrong`),
|
||||
description: _(msg`We were unable to verify your code. Please try again.`),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (isValidatingChallenge) {
|
||||
return (
|
||||
<div className="w-screen max-w-lg px-4">
|
||||
<div className="flex flex-col items-center justify-center gap-y-4 py-12">
|
||||
<Loader2Icon className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
<p className="text-muted-foreground text-sm">
|
||||
<Trans>Checking your sign-in...</Trans>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-screen max-w-lg px-4">
|
||||
<div className="z-10 rounded-xl border border-border bg-neutral-100 p-6 dark:bg-background">
|
||||
<h1 className="font-semibold text-2xl">
|
||||
<Trans>Two-Factor Authentication</Trans>
|
||||
</h1>
|
||||
|
||||
<p className="mt-2 text-muted-foreground text-sm">
|
||||
{twoFactorAuthenticationMethod === 'totp' ? (
|
||||
<Trans>Enter the code from your authenticator app to finish signing in.</Trans>
|
||||
) : (
|
||||
<Trans>Enter one of your backup codes to finish signing in.</Trans>
|
||||
)}
|
||||
</p>
|
||||
|
||||
<hr className="-mx-6 my-4" />
|
||||
|
||||
<Form {...form}>
|
||||
<form className="flex w-full flex-col gap-y-4" onSubmit={form.handleSubmit(onFormSubmit)}>
|
||||
<fieldset className="flex w-full flex-col gap-y-4" disabled={isSubmitting}>
|
||||
{twoFactorAuthenticationMethod === 'totp' && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="totpCode"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Token</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<PinInput {...field} value={field.value ?? ''} maxLength={6}>
|
||||
{Array(6)
|
||||
.fill(null)
|
||||
.map((_, i) => (
|
||||
<PinInputGroup key={i}>
|
||||
<PinInputSlot index={i} />
|
||||
</PinInputGroup>
|
||||
))}
|
||||
</PinInput>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{twoFactorAuthenticationMethod === 'backup' && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="backupCode"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Backup Code</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="text" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-y-2 sm:flex-row sm:justify-end sm:gap-x-2">
|
||||
<Button type="button" variant="secondary" onClick={onToggleTwoFactorAuthenticationMethodClick}>
|
||||
{twoFactorAuthenticationMethod === 'totp' ? (
|
||||
<Trans>Use Backup Code</Trans>
|
||||
) : (
|
||||
<Trans>Use Authenticator</Trans>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<Button type="submit" loading={isSubmitting}>
|
||||
{isSubmitting ? <Trans>Signing in...</Trans> : <Trans>Sign In</Trans>}
|
||||
</Button>
|
||||
</div>
|
||||
</fieldset>
|
||||
</form>
|
||||
</Form>
|
||||
|
||||
<p className="mt-6 text-center text-muted-foreground text-sm">
|
||||
<Trans>
|
||||
Not you?{' '}
|
||||
<Link to="/signin" className="text-documenso-700 duration-200 hover:opacity-70">
|
||||
Back to sign in
|
||||
</Link>
|
||||
</Trans>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -32,6 +32,12 @@ export async function loader({ params, request }: Route.LoaderArgs) {
|
||||
organisation: {
|
||||
select: {
|
||||
name: true,
|
||||
organisationGlobalSettings: {
|
||||
select: {
|
||||
twoFactorRequired: true,
|
||||
twoFactorGracePeriodDays: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -71,6 +77,10 @@ export async function loader({ params, request }: Route.LoaderArgs) {
|
||||
},
|
||||
});
|
||||
|
||||
// Non-blocking notice data: joining always succeeds, but the member's 2FA
|
||||
// grace window starts at join when the organisation requires 2FA.
|
||||
const twoFactorSettings = organisationMemberInvite.organisation.organisationGlobalSettings;
|
||||
|
||||
return {
|
||||
state: 'Pending',
|
||||
token: organisationMemberInvite.token,
|
||||
@@ -78,6 +88,8 @@ export async function loader({ params, request }: Route.LoaderArgs) {
|
||||
organisationName,
|
||||
userExists: user !== null,
|
||||
isSessionUserTheInvitedUser: user !== null && user.id === session.user?.id,
|
||||
organisationTwoFactorRequired: twoFactorSettings.twoFactorRequired,
|
||||
organisationTwoFactorGracePeriodDays: twoFactorSettings.twoFactorGracePeriodDays,
|
||||
} as const;
|
||||
}
|
||||
|
||||
@@ -141,6 +153,8 @@ export default function AcceptInvitationPage({ loaderData }: Route.ComponentProp
|
||||
organisationName={data.organisationName}
|
||||
userExists={data.userExists}
|
||||
isSessionUserTheInvitedUser={data.isSessionUserTheInvitedUser}
|
||||
organisationTwoFactorRequired={data.organisationTwoFactorRequired}
|
||||
organisationTwoFactorGracePeriodDays={data.organisationTwoFactorGracePeriodDays}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -151,6 +165,8 @@ type PendingInvitationProps = {
|
||||
organisationName: string;
|
||||
userExists: boolean;
|
||||
isSessionUserTheInvitedUser: boolean;
|
||||
organisationTwoFactorRequired: boolean;
|
||||
organisationTwoFactorGracePeriodDays: number;
|
||||
};
|
||||
|
||||
type InvitationResult = 'idle' | 'accepted' | 'declined';
|
||||
@@ -163,6 +179,8 @@ const PendingInvitation = ({
|
||||
organisationName,
|
||||
userExists,
|
||||
isSessionUserTheInvitedUser,
|
||||
organisationTwoFactorRequired,
|
||||
organisationTwoFactorGracePeriodDays,
|
||||
}: PendingInvitationProps) => {
|
||||
const { t } = useLingui();
|
||||
const { toast } = useToast();
|
||||
@@ -289,6 +307,24 @@ const PendingInvitation = ({
|
||||
</Trans>
|
||||
</p>
|
||||
|
||||
{/* Non-blocking notice: accepting always succeeds; access to the
|
||||
organisation blocks only after the grace period expires. */}
|
||||
{organisationTwoFactorRequired && !actionIsDecline && (
|
||||
<p className="mt-2 mb-4 text-muted-foreground text-sm">
|
||||
{organisationTwoFactorGracePeriodDays > 0 ? (
|
||||
<Trans>
|
||||
This organisation requires two-factor authentication. You will need to enable it within{' '}
|
||||
{organisationTwoFactorGracePeriodDays} days of joining to keep access to the organisation.
|
||||
</Trans>
|
||||
) : (
|
||||
<Trans>
|
||||
This organisation requires two-factor authentication. You will need to enable it immediately after
|
||||
joining to access the organisation.
|
||||
</Trans>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{acceptFailureReason && (
|
||||
<p className="mt-2 mb-4 text-destructive text-sm">
|
||||
{match(acceptFailureReason)
|
||||
|
||||
@@ -86,6 +86,12 @@ export async function loader({ params }: Route.LoaderArgs) {
|
||||
name: true,
|
||||
url: true,
|
||||
avatarImageId: true,
|
||||
organisationGlobalSettings: {
|
||||
select: {
|
||||
twoFactorRequired: true,
|
||||
twoFactorGracePeriodDays: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -107,6 +113,10 @@ export async function loader({ params }: Route.LoaderArgs) {
|
||||
name: organisation.name,
|
||||
url: organisation.url,
|
||||
avatar: organisation.avatarImageId,
|
||||
// Non-blocking notice data: SSO membership creation always succeeds;
|
||||
// the 2FA grace window starts at join.
|
||||
twoFactorRequired: organisation.organisationGlobalSettings.twoFactorRequired,
|
||||
twoFactorGracePeriodDays: organisation.organisationGlobalSettings.twoFactorGracePeriodDays,
|
||||
},
|
||||
} as const;
|
||||
}
|
||||
@@ -266,6 +276,26 @@ export default function OrganisationSsoConfirmationTokenPage({ loaderData }: Rou
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Non-blocking notice: confirming always succeeds; organisation
|
||||
access blocks only after the grace period expires. */}
|
||||
{organisation.twoFactorRequired && (
|
||||
<Alert variant="neutral">
|
||||
<AlertDescription>
|
||||
{organisation.twoFactorGracePeriodDays > 0 ? (
|
||||
<Trans>
|
||||
This organisation requires two-factor authentication. You will need to enable it within{' '}
|
||||
{organisation.twoFactorGracePeriodDays} days of joining to keep access to the organisation.
|
||||
</Trans>
|
||||
) : (
|
||||
<Trans>
|
||||
This organisation requires two-factor authentication. You will need to enable it immediately after
|
||||
joining to access the organisation.
|
||||
</Trans>
|
||||
)}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="mb-4 flex items-center gap-x-2">
|
||||
<Checkbox
|
||||
id={`accept-conditions`}
|
||||
|
||||
@@ -137,6 +137,9 @@ export default function AuthoringLayout() {
|
||||
teams: [team],
|
||||
subscription: null,
|
||||
currentOrganisationRole: OrganisationMemberRole.MEMBER,
|
||||
// Hardcoded non-enforcing status: embed authoring is presign-token
|
||||
// authorized (machine access), which is exempt from 2FA enforcement.
|
||||
twoFactorEnforcement: { required: false },
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,385 @@
|
||||
import { authClient } from '@documenso/auth/client';
|
||||
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
|
||||
import { downloadFile } from '@documenso/lib/client-only/download-file';
|
||||
import { AppError } from '@documenso/lib/errors/app-error';
|
||||
import { getTwoFactorEnforcementStatus } from '@documenso/lib/server-only/2fa/get-two-factor-enforcement-status';
|
||||
import { isValidReturnTo, normalizeReturnTo } from '@documenso/lib/utils/is-valid-return-to';
|
||||
import { isTwoFactorSatisfied } from '@documenso/lib/utils/two-factor';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@documenso/ui/primitives/form/form';
|
||||
import { PinInput, PinInputGroup, PinInputSlot } from '@documenso/ui/primitives/pin-input';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { Loader2Icon } from 'lucide-react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { Link, redirect, useNavigate, useRevalidator } from 'react-router';
|
||||
import { renderSVG } from 'uqr';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { RecoveryCodeList } from '~/components/forms/2fa/recovery-code-list';
|
||||
import { appMetaTags } from '~/utils/meta';
|
||||
import { superLoaderJson, useSuperLoaderData } from '~/utils/super-json-loader';
|
||||
|
||||
import type { Route } from './+types/2fa';
|
||||
|
||||
export function meta() {
|
||||
return appMetaTags(msg`Two-Factor Authentication`);
|
||||
}
|
||||
|
||||
export async function loader({ request }: Route.LoaderArgs) {
|
||||
const session = await getOptionalSession(request);
|
||||
|
||||
const url = new URL(request.url);
|
||||
|
||||
const rawReturnTo = url.searchParams.get('returnTo') ?? undefined;
|
||||
const returnTo = (isValidReturnTo(rawReturnTo) && normalizeReturnTo(rawReturnTo)) || '/';
|
||||
|
||||
if (!session.isAuthenticated) {
|
||||
throw redirect(`/signin?returnTo=${encodeURIComponent(`${url.pathname}${url.search}`)}`);
|
||||
}
|
||||
|
||||
// Auto-redirect ONLY when enforcement is already satisfied on arrival.
|
||||
// Every other state (including "not required") renders the page — a user
|
||||
// landing here after a backup-code recovery gets an explicit skip link
|
||||
// instead of being bounced away.
|
||||
if (
|
||||
isTwoFactorSatisfied({
|
||||
userTwoFactorEnabled: session.user.twoFactorEnabled,
|
||||
sessionTwoFactorVerified: session.session.twoFactorVerified,
|
||||
})
|
||||
) {
|
||||
throw redirect(returnTo);
|
||||
}
|
||||
|
||||
const twoFactorEnforcement = await getTwoFactorEnforcementStatus({
|
||||
user: session.user,
|
||||
session: session.session,
|
||||
});
|
||||
|
||||
return superLoaderJson({
|
||||
returnTo,
|
||||
isTwoFactorEnabled: session.user.twoFactorEnabled,
|
||||
isSessionTwoFactorVerified: session.session.twoFactorVerified,
|
||||
twoFactorEnforcement,
|
||||
});
|
||||
}
|
||||
|
||||
const ZEnableTwoFactorFormSchema = z.object({
|
||||
token: z.string().min(6).max(6),
|
||||
});
|
||||
|
||||
type TEnableTwoFactorFormSchema = z.infer<typeof ZEnableTwoFactorFormSchema>;
|
||||
|
||||
export default function OnboardingTwoFactorPage() {
|
||||
const { returnTo, isTwoFactorEnabled, isSessionTwoFactorVerified, twoFactorEnforcement } =
|
||||
useSuperLoaderData<typeof loader>();
|
||||
|
||||
const { _, i18n } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
const navigate = useNavigate();
|
||||
const { revalidate } = useRevalidator();
|
||||
|
||||
// The whole page renders from the loader snapshot + local state, never from
|
||||
// the live session context. The session provider refreshes in the
|
||||
// background (and `twoFactorEnabled` flips the moment 2FA is enabled), but
|
||||
// navigation is controlled exclusively by this page's state machine —
|
||||
// recovery codes are shown exactly once and must stay on screen until the
|
||||
// user explicitly acknowledges saving them.
|
||||
const [setupData, setSetupData] = useState<{ uri: string; secret: string } | null>(null);
|
||||
const [recoveryCodes, setRecoveryCodes] = useState<string[] | null>(null);
|
||||
const [hasSetupFailed, setHasSetupFailed] = useState(false);
|
||||
|
||||
const hasRequestedSetupRef = useRef(false);
|
||||
|
||||
const isBlocked = twoFactorEnforcement.required && twoFactorEnforcement.isBlocked;
|
||||
const canSkip = !isBlocked;
|
||||
|
||||
// State (b): enrolled, but this session was created before 2FA was enabled
|
||||
// so it never passed a second factor. Setup would rightly refuse
|
||||
// (already enabled), so the only remediation is a fresh sign-in.
|
||||
const requiresRelogin = isTwoFactorEnabled && !isSessionTwoFactorVerified;
|
||||
|
||||
const form = useForm<TEnableTwoFactorFormSchema>({
|
||||
defaultValues: {
|
||||
token: '',
|
||||
},
|
||||
resolver: zodResolver(ZEnableTwoFactorFormSchema),
|
||||
});
|
||||
|
||||
const { isSubmitting: isEnabling } = form.formState;
|
||||
|
||||
// Enrolment goes through the auth routes (`authClient.twoFactor.*`), NOT
|
||||
// tRPC: while the user is blocked by instance enforcement, session tRPC
|
||||
// procedures respond 403 — the remediation page must not depend on them.
|
||||
const setupTwoFactor = async () => {
|
||||
setHasSetupFailed(false);
|
||||
|
||||
try {
|
||||
const data = await authClient.twoFactor.setup();
|
||||
|
||||
setSetupData(data);
|
||||
} catch (err) {
|
||||
const error = AppError.parseError(err);
|
||||
|
||||
// The user enrolled concurrently (e.g. in another tab). Re-run the
|
||||
// loader instead of dead-ending on a retry that would refuse forever:
|
||||
// it auto-redirects when this session became verified by the
|
||||
// concurrent enable, or renders the sign-out-and-re-login prompt.
|
||||
if (error.code === 'TWO_FACTOR_ALREADY_ENABLED') {
|
||||
await revalidate();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
setHasSetupFailed(true);
|
||||
|
||||
toast({
|
||||
title: _(msg`Unable to setup two-factor authentication`),
|
||||
description: _(msg`We were unable to setup two-factor authentication for your account. Please try again.`),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isTwoFactorEnabled || hasRequestedSetupRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
hasRequestedSetupRef.current = true;
|
||||
|
||||
void setupTwoFactor();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const onEnableSubmit = async ({ token }: TEnableTwoFactorFormSchema) => {
|
||||
try {
|
||||
const data = await authClient.twoFactor.enable({ code: token });
|
||||
|
||||
// Phase one complete. Do NOT navigate — show the recovery codes and
|
||||
// wait for the explicit acknowledgement below.
|
||||
setRecoveryCodes(data.recoveryCodes);
|
||||
} catch (err) {
|
||||
const error = AppError.parseError(err);
|
||||
|
||||
// Enabled concurrently (e.g. another tab) between setup and enable —
|
||||
// the recovery codes were shown there. Re-run the loader to land on
|
||||
// the correct state instead of claiming the code was wrong.
|
||||
if (error.code === 'TWO_FACTOR_ALREADY_ENABLED') {
|
||||
toast({
|
||||
title: _(msg`Two-factor authentication is already enabled`),
|
||||
description: _(msg`Two-factor authentication was already enabled for your account.`),
|
||||
});
|
||||
|
||||
await revalidate();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
toast({
|
||||
title: _(msg`Unable to setup two-factor authentication`),
|
||||
description: _(
|
||||
msg`We were unable to setup two-factor authentication for your account. Please ensure that you have entered your code correctly and try again.`,
|
||||
),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const onDownloadRecoveryCodes = () => {
|
||||
if (!recoveryCodes) {
|
||||
return;
|
||||
}
|
||||
|
||||
const blob = new Blob([recoveryCodes.join('\n')], {
|
||||
type: 'text/plain',
|
||||
});
|
||||
|
||||
downloadFile({
|
||||
filename: 'documenso-2FA-recovery-codes.txt',
|
||||
data: blob,
|
||||
});
|
||||
};
|
||||
|
||||
// Phase two: only the explicit acknowledgement navigates away.
|
||||
const onRecoveryCodesAcknowledged = async () => {
|
||||
await navigate(returnTo);
|
||||
};
|
||||
|
||||
const onSignOut = async () => {
|
||||
await authClient.signOut();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-screen max-w-lg px-4">
|
||||
<div className="z-10 rounded-xl border border-border bg-neutral-100 p-6 dark:bg-background">
|
||||
<h1 className="font-semibold text-2xl">
|
||||
<Trans>Two-factor authentication</Trans>
|
||||
</h1>
|
||||
|
||||
{twoFactorEnforcement.required && isBlocked && (
|
||||
<p className="mt-2 text-muted-foreground text-sm">
|
||||
<Trans>Two-factor authentication is required to continue using your account.</Trans>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{twoFactorEnforcement.required && !isBlocked && (
|
||||
<p className="mt-2 text-muted-foreground text-sm">
|
||||
<Trans>
|
||||
Two-factor authentication is required for your account from{' '}
|
||||
{i18n.date(twoFactorEnforcement.deadline, { dateStyle: 'long' })}.
|
||||
</Trans>
|
||||
</p>
|
||||
)}
|
||||
|
||||
<hr className="-mx-6 my-4" />
|
||||
|
||||
{requiresRelogin ? (
|
||||
<div className="flex flex-col gap-y-4">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
<Trans>
|
||||
Two-factor authentication is enabled for your account, but this session has not been verified with a
|
||||
second factor. Sign out and log back in to verify this session.
|
||||
</Trans>
|
||||
</p>
|
||||
|
||||
<Button className="w-full sm:w-auto sm:self-end" onClick={() => void onSignOut()}>
|
||||
<Trans>Sign out</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
) : recoveryCodes ? (
|
||||
<div className="flex flex-col gap-y-4">
|
||||
<div>
|
||||
<h2 className="font-medium text-lg">
|
||||
<Trans>Save your recovery codes</Trans>
|
||||
</h2>
|
||||
|
||||
<p className="mt-1 text-muted-foreground text-sm">
|
||||
<Trans>
|
||||
Your recovery codes are listed below. Please store them in a safe place — they will not be shown
|
||||
again.
|
||||
</Trans>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<RecoveryCodeList recoveryCodes={recoveryCodes} />
|
||||
|
||||
<div className="flex flex-col gap-y-2 sm:flex-row sm:justify-end sm:gap-x-2">
|
||||
<Button variant="secondary" onClick={onDownloadRecoveryCodes}>
|
||||
<Trans>Download</Trans>
|
||||
</Button>
|
||||
|
||||
<Button onClick={() => void onRecoveryCodesAcknowledged()}>
|
||||
<Trans>I have saved my recovery codes</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : !setupData ? (
|
||||
<div className="flex flex-col items-center justify-center gap-y-4 py-12">
|
||||
{hasSetupFailed ? (
|
||||
<>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
<Trans>We were unable to prepare two-factor authentication.</Trans>
|
||||
</p>
|
||||
|
||||
<Button variant="secondary" onClick={() => void setupTwoFactor()}>
|
||||
<Trans>Try again</Trans>
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Loader2Icon className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
|
||||
<p className="text-muted-foreground text-sm">
|
||||
<Trans>Preparing two-factor authentication...</Trans>
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onEnableSubmit)}>
|
||||
<fieldset disabled={isEnabling} className="flex flex-col gap-y-4">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
<Trans>
|
||||
To enable two-factor authentication, scan the following QR code using your authenticator app.
|
||||
</Trans>
|
||||
</p>
|
||||
|
||||
<div
|
||||
className="flex h-36 justify-center"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: renderSVG(setupData.uri),
|
||||
}}
|
||||
/>
|
||||
|
||||
<p className="text-muted-foreground text-sm">
|
||||
<Trans>
|
||||
If your authenticator app does not support QR codes, you can use the following code instead:
|
||||
</Trans>
|
||||
</p>
|
||||
|
||||
<p className="rounded-lg bg-muted/60 p-2 text-center font-mono text-muted-foreground tracking-widest">
|
||||
{setupData.secret}
|
||||
</p>
|
||||
|
||||
<FormField
|
||||
name="token"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-muted-foreground">
|
||||
<Trans>Token</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<PinInput {...field} value={field.value ?? ''} maxLength={6}>
|
||||
{Array(6)
|
||||
.fill(null)
|
||||
.map((_, i) => (
|
||||
<PinInputGroup key={i}>
|
||||
<PinInputSlot index={i} />
|
||||
</PinInputGroup>
|
||||
))}
|
||||
</PinInput>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button type="submit" loading={isEnabling} className="w-full sm:w-auto sm:self-end">
|
||||
<Trans>Enable 2FA</Trans>
|
||||
</Button>
|
||||
</fieldset>
|
||||
</form>
|
||||
</Form>
|
||||
)}
|
||||
|
||||
{(canSkip || !requiresRelogin) && (
|
||||
<p className="mt-6 flex flex-col items-center gap-y-1 text-center text-muted-foreground text-sm">
|
||||
{canSkip && !recoveryCodes && (
|
||||
<Link to={returnTo} className="text-documenso-700 duration-200 hover:opacity-70">
|
||||
<Trans>Skip for now</Trans>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{!requiresRelogin && (
|
||||
<button
|
||||
type="button"
|
||||
className="text-documenso-700 duration-200 hover:opacity-70"
|
||||
onClick={() => void onSignOut()}
|
||||
>
|
||||
<Trans>Sign out</Trans>
|
||||
</button>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import backgroundPattern from '@documenso/assets/images/background-pattern.png';
|
||||
import { Outlet } from 'react-router';
|
||||
|
||||
/**
|
||||
* Onboarding routes require a session (each route's own loader asserts it)
|
||||
* but deliberately live OUTSIDE the `_authenticated+` layout: that layout is
|
||||
* the UX chokepoint for 2FA enforcement redirects, and remediation pages such
|
||||
* as `/onboarding/2fa` must be exempt from it or the redirect would loop.
|
||||
*/
|
||||
export default function Layout() {
|
||||
return (
|
||||
<main className="relative flex min-h-screen flex-col items-center justify-center overflow-hidden px-4 py-12 md:p-12 lg:p-24">
|
||||
<div>
|
||||
<div className="absolute -inset-[min(600px,max(400px,60vw))] -z-[1] flex items-center justify-center opacity-70">
|
||||
<img
|
||||
src={backgroundPattern}
|
||||
alt="background pattern"
|
||||
className="dark:brightness-95 dark:contrast-[70%] dark:invert dark:sepia"
|
||||
style={{
|
||||
mask: 'radial-gradient(rgba(255, 255, 255, 1) 0%, transparent 80%)',
|
||||
WebkitMask: 'radial-gradient(rgba(255, 255, 255, 1) 0%, transparent 80%)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="relative w-full">
|
||||
<Outlet />
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { getSession } from '@documenso/auth/server/lib/utils/get-session';
|
||||
import { IS_AI_FEATURES_CONFIGURED } from '@documenso/lib/constants/app';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { assertTwoFactorEnforcementForSession } from '@documenso/lib/server-only/2fa/org-enforcement';
|
||||
import { detectFieldsFromEnvelope } from '@documenso/lib/server-only/ai/envelope/detect-fields';
|
||||
import { getTeamById } from '@documenso/lib/server-only/team/get-team';
|
||||
import { sValidator } from '@hono/standard-validator';
|
||||
@@ -41,6 +42,14 @@ export const detectFieldsRoute = new Hono<HonoEnv>().post(
|
||||
});
|
||||
}
|
||||
|
||||
// 2FA enforcement: session-authenticated endpoint — instance assert +
|
||||
// the owning organisation's policy for the envelope's team.
|
||||
await assertTwoFactorEnforcementForSession({
|
||||
user: session.user,
|
||||
session: session.session,
|
||||
organisationIds: [team.organisationId],
|
||||
});
|
||||
|
||||
// Check if AI features are enabled for the team
|
||||
const { aiFeaturesEnabled } = team.derivedSettings;
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { getSession } from '@documenso/auth/server/lib/utils/get-session';
|
||||
import { IS_AI_FEATURES_CONFIGURED } from '@documenso/lib/constants/app';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { assertTwoFactorEnforcementForSession } from '@documenso/lib/server-only/2fa/org-enforcement';
|
||||
import { detectRecipientsFromEnvelope } from '@documenso/lib/server-only/ai/envelope/detect-recipients';
|
||||
import { getTeamById } from '@documenso/lib/server-only/team/get-team';
|
||||
import { sValidator } from '@hono/standard-validator';
|
||||
@@ -41,6 +42,14 @@ export const detectRecipientsRoute = new Hono<HonoEnv>().post(
|
||||
});
|
||||
}
|
||||
|
||||
// 2FA enforcement: session-authenticated endpoint — instance assert +
|
||||
// the owning organisation's policy for the envelope's team.
|
||||
await assertTwoFactorEnforcementForSession({
|
||||
user: session.user,
|
||||
session: session.session,
|
||||
organisationIds: [team.organisationId],
|
||||
});
|
||||
|
||||
// Check if AI features are enabled for the team
|
||||
const { aiFeaturesEnabled } = team.derivedSettings;
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
|
||||
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
|
||||
import { AppError } from '@documenso/lib/errors/app-error';
|
||||
import { assertTwoFactorEnforcementForSession } from '@documenso/lib/server-only/2fa/org-enforcement';
|
||||
import { verifyEmbeddingPresignToken } from '@documenso/lib/server-only/embedding-presign/verify-embedding-presign-token';
|
||||
import { putNormalizedPdfFileServerSide } from '@documenso/lib/universal/upload/put-file.server';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
@@ -28,6 +29,17 @@ export const filesRoute = new Hono<HonoEnv>()
|
||||
*/
|
||||
.post('/upload-pdf', sValidator('form', ZUploadPdfRequestSchema), async (c) => {
|
||||
try {
|
||||
// 2FA enforcement applies only when the request is session
|
||||
// authenticated — presign-token access (embedding) is machine access
|
||||
// and stays exempt. `resolveFileUploadUserId` prefers the session, so
|
||||
// asserting on the session here cannot be bypassed by a session user.
|
||||
const { user: sessionUser, session } = await getOptionalSession(c);
|
||||
|
||||
if (sessionUser && session) {
|
||||
// No organisation scope: the upload creates unattached document data.
|
||||
await assertTwoFactorEnforcementForSession({ user: sessionUser, session });
|
||||
}
|
||||
|
||||
const userId = await resolveFileUploadUserId(c);
|
||||
|
||||
if (!userId) {
|
||||
@@ -54,6 +66,13 @@ export const filesRoute = new Hono<HonoEnv>()
|
||||
return c.json(result);
|
||||
} catch (error) {
|
||||
console.error('Upload failed:', error);
|
||||
|
||||
if (error instanceof AppError) {
|
||||
const { status, body } = AppError.toRestAPIError(error);
|
||||
|
||||
return c.json({ error: body.message, code: error.code }, status);
|
||||
}
|
||||
|
||||
return c.json({ error: 'Upload failed' }, 500);
|
||||
}
|
||||
})
|
||||
@@ -69,6 +88,10 @@ export const filesRoute = new Hono<HonoEnv>()
|
||||
|
||||
let userId = session.user?.id;
|
||||
|
||||
// Presign-token access (embedding) is machine access and exempt from
|
||||
// 2FA enforcement; the assert below only applies to session auth.
|
||||
const isPresignTokenAccess = Boolean(token);
|
||||
|
||||
if (token) {
|
||||
const presignToken = await verifyEmbeddingPresignToken({
|
||||
token,
|
||||
@@ -86,6 +109,11 @@ export const filesRoute = new Hono<HonoEnv>()
|
||||
id: envelopeId,
|
||||
},
|
||||
include: {
|
||||
team: {
|
||||
select: {
|
||||
organisationId: true,
|
||||
},
|
||||
},
|
||||
envelopeItems: {
|
||||
where: {
|
||||
id: envelopeItemId,
|
||||
@@ -101,6 +129,26 @@ export const filesRoute = new Hono<HonoEnv>()
|
||||
return c.json({ error: 'Envelope not found' }, 404);
|
||||
}
|
||||
|
||||
// 2FA enforcement (session auth only): instance assert + the owning
|
||||
// organisation's policy for the envelope being accessed.
|
||||
if (!isPresignTokenAccess && session.user && session.session) {
|
||||
try {
|
||||
await assertTwoFactorEnforcementForSession({
|
||||
user: session.user,
|
||||
session: session.session,
|
||||
organisationIds: [envelope.team.organisationId],
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) {
|
||||
const { status, body } = AppError.toRestAPIError(error);
|
||||
|
||||
return c.json({ error: body.message, code: error.code }, status);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
const [envelopeItem] = envelope.envelopeItems;
|
||||
|
||||
if (!envelopeItem) {
|
||||
@@ -152,6 +200,11 @@ export const filesRoute = new Hono<HonoEnv>()
|
||||
id: envelopeId,
|
||||
},
|
||||
include: {
|
||||
team: {
|
||||
select: {
|
||||
organisationId: true,
|
||||
},
|
||||
},
|
||||
envelopeItems: {
|
||||
where: {
|
||||
id: envelopeItemId,
|
||||
@@ -173,6 +226,15 @@ export const filesRoute = new Hono<HonoEnv>()
|
||||
return c.json({ error: 'Envelope not found' }, 404);
|
||||
}
|
||||
|
||||
// 2FA enforcement: this route is session-only, so both the instance
|
||||
// assert and the owning organisation's policy apply. The thrown
|
||||
// AppError is mapped to a 403 by the catch below.
|
||||
await assertTwoFactorEnforcementForSession({
|
||||
user: session.user,
|
||||
session: session.session,
|
||||
organisationIds: [envelope.team.organisationId],
|
||||
});
|
||||
|
||||
const [envelopeItem] = envelope.envelopeItems;
|
||||
|
||||
if (!envelopeItem) {
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
|
||||
import { AppError } from '@documenso/lib/errors/app-error';
|
||||
import { assertTwoFactorEnforcementForSession } from '@documenso/lib/server-only/2fa/org-enforcement';
|
||||
import { verifyEmbeddingPresignToken } from '@documenso/lib/server-only/embedding-presign/verify-embedding-presign-token';
|
||||
import type { DocumentDataVersion } from '@documenso/lib/types/document';
|
||||
import { sha256 } from '@documenso/lib/universal/crypto';
|
||||
@@ -41,6 +43,10 @@ route.get(
|
||||
|
||||
let userId = session.user?.id;
|
||||
|
||||
// Presign-token access (embedding) is machine access and exempt from 2FA
|
||||
// enforcement; the assert below only applies to session auth.
|
||||
const isPresignTokenAccess = Boolean(presignToken);
|
||||
|
||||
// Check presignToken if provided
|
||||
if (presignToken) {
|
||||
const verifiedToken = await verifyEmbeddingPresignToken({
|
||||
@@ -69,6 +75,11 @@ route.get(
|
||||
type: true,
|
||||
teamId: true,
|
||||
templateType: true,
|
||||
team: {
|
||||
select: {
|
||||
organisationId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -78,6 +89,26 @@ route.get(
|
||||
return c.json({ error: 'Not found' }, 404);
|
||||
}
|
||||
|
||||
// 2FA enforcement (session auth only): instance assert + the owning
|
||||
// organisation's policy for the envelope being accessed.
|
||||
if (!isPresignTokenAccess && session.user && session.session) {
|
||||
try {
|
||||
await assertTwoFactorEnforcementForSession({
|
||||
user: session.user,
|
||||
session: session.session,
|
||||
organisationIds: [envelopeItem.envelope.team.organisationId],
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) {
|
||||
const { status, body } = AppError.toRestAPIError(error);
|
||||
|
||||
return c.json({ error: body.message, code: error.code }, status);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Check whether the user has access to the document.
|
||||
const hasAccess = await checkEnvelopeFileAccess({
|
||||
userId,
|
||||
|
||||
@@ -6,6 +6,12 @@ type LoginOptions = {
|
||||
email?: string;
|
||||
password?: string;
|
||||
|
||||
/**
|
||||
* TOTP code for accounts with 2FA enabled. Sign-ins that pass a valid code
|
||||
* create a session with `twoFactorVerified: true`.
|
||||
*/
|
||||
totpCode?: string;
|
||||
|
||||
/**
|
||||
* Where to navigate after login.
|
||||
*/
|
||||
@@ -16,6 +22,7 @@ export const apiSignin = async ({
|
||||
page,
|
||||
email = 'example@documenso.com',
|
||||
password = 'password',
|
||||
totpCode,
|
||||
redirectPath = '/',
|
||||
}: LoginOptions) => {
|
||||
const { request } = page.context();
|
||||
@@ -26,6 +33,7 @@ export const apiSignin = async ({
|
||||
data: {
|
||||
email,
|
||||
password,
|
||||
totpCode,
|
||||
csrfToken,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import crypto from 'node:crypto';
|
||||
import { DOCUMENSO_ENCRYPTION_KEY } from '@documenso/lib/constants/crypto';
|
||||
import { symmetricEncrypt } from '@documenso/lib/universal/crypto';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { base32 } from '@scure/base';
|
||||
import { generateHOTP } from 'oslo/otp';
|
||||
|
||||
/**
|
||||
* Must match the period used by `verifyTwoFactorAuthenticationToken` in
|
||||
* `packages/lib/server-only/2fa/verify-2fa-token.ts`.
|
||||
*/
|
||||
const TOTP_PERIOD_MS = 30_000;
|
||||
|
||||
/**
|
||||
* Enables 2FA for an existing user using the exact storage format the server
|
||||
* writes in `setup-2fa.ts`/`enable-2fa.ts` (base32 TOTP secret + JSON backup
|
||||
* codes, both symmetrically encrypted with the instance encryption key).
|
||||
*
|
||||
* No mocks: the server validates codes generated from this secret with the
|
||||
* same OTP library used here.
|
||||
*/
|
||||
export const seedUserTwoFactorAuthentication = async ({ userId }: { userId: number }) => {
|
||||
const key = DOCUMENSO_ENCRYPTION_KEY;
|
||||
|
||||
if (!key) {
|
||||
throw new Error('NEXT_PRIVATE_ENCRYPTION_KEY must be set to seed 2FA users');
|
||||
}
|
||||
|
||||
const secret = crypto.randomBytes(10);
|
||||
|
||||
const backupCodes = Array.from({ length: 10 })
|
||||
.fill(null)
|
||||
.map(() => crypto.randomBytes(5).toString('hex'))
|
||||
.map((code) => `${code.slice(0, 5)}-${code.slice(5)}`.toUpperCase());
|
||||
|
||||
await prisma.user.update({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
data: {
|
||||
twoFactorEnabled: true,
|
||||
twoFactorSecret: symmetricEncrypt({
|
||||
key,
|
||||
data: base32.encode(new Uint8Array(secret)),
|
||||
}),
|
||||
twoFactorBackupCodes: symmetricEncrypt({
|
||||
key,
|
||||
data: JSON.stringify(backupCodes),
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
secret,
|
||||
backupCodes,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Computes the TOTP code for the current time window, mirroring the server's
|
||||
* verification (`generateHOTP` with a 30 second period).
|
||||
*/
|
||||
export const generateTotpCode = async ({ secret }: { secret: Buffer }) => {
|
||||
return await generateHOTP(new Uint8Array(secret), Math.floor(Date.now() / TOTP_PERIOD_MS));
|
||||
};
|
||||
|
||||
/**
|
||||
* The server only accepts the code for the current 30s window (window = 1).
|
||||
* If we are close to a window boundary, wait for the next window so the code
|
||||
* cannot expire between generation and verification.
|
||||
*/
|
||||
export const waitForStableTotpWindow = async () => {
|
||||
const remainingMs = TOTP_PERIOD_MS - (Date.now() % TOTP_PERIOD_MS);
|
||||
|
||||
if (remainingMs < 5_000) {
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, remainingMs + 250);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
type SeedOrganisationTwoFactorEnforcementOptions = {
|
||||
organisationId: string;
|
||||
twoFactorRequired?: boolean;
|
||||
twoFactorGracePeriodDays?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Directly seeds the organisation-level 2FA enforcement settings, bypassing
|
||||
* the tRPC settings write path (which is covered by its own tests).
|
||||
*/
|
||||
export const seedOrganisationTwoFactorEnforcement = async ({
|
||||
organisationId,
|
||||
twoFactorRequired = true,
|
||||
twoFactorGracePeriodDays = 0,
|
||||
}: SeedOrganisationTwoFactorEnforcementOptions) => {
|
||||
await prisma.organisation.update({
|
||||
where: {
|
||||
id: organisationId,
|
||||
},
|
||||
data: {
|
||||
organisationGlobalSettings: {
|
||||
update: {
|
||||
twoFactorRequired,
|
||||
twoFactorGracePeriodDays,
|
||||
twoFactorEnforcedFrom: twoFactorRequired ? new Date() : null,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,272 @@
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { nanoid } from '@documenso/lib/universal/id';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { seedOrganisationMembers } from '@documenso/prisma/seed/organisations';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { apiSignin, apiSignout } from '../fixtures/authentication';
|
||||
import {
|
||||
generateTotpCode,
|
||||
seedOrganisationTwoFactorEnforcement,
|
||||
seedUserTwoFactorAuthentication,
|
||||
waitForStableTotpWindow,
|
||||
} from '../fixtures/two-factor';
|
||||
|
||||
test('[ORGANISATIONS]: joining succeeds then org context is blocked at 0-day grace', async ({ page }) => {
|
||||
const { user: owner, organisation, team } = await seedUser({ isPersonalOrganisation: false });
|
||||
|
||||
// The owner must satisfy the policy themselves to use the org settings
|
||||
// pages while enforcement is active with a 0-day grace period.
|
||||
const { secret: ownerSecret } = await seedUserTwoFactorAuthentication({ userId: owner.id });
|
||||
|
||||
await seedOrganisationTwoFactorEnforcement({
|
||||
organisationId: organisation.id,
|
||||
twoFactorGracePeriodDays: 0,
|
||||
});
|
||||
|
||||
const { user: member } = await seedUser({ isPersonalOrganisation: false });
|
||||
|
||||
await waitForStableTotpWindow();
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: owner.email,
|
||||
totpCode: await generateTotpCode({ secret: ownerSecret }),
|
||||
redirectPath: `/o/${organisation.url}/settings/members`,
|
||||
});
|
||||
|
||||
// Invite the member while the 0-day enforcement policy is already active.
|
||||
await page.getByRole('button', { name: 'Invite member' }).click();
|
||||
await page.getByRole('textbox', { name: 'Email address *' }).fill(member.email);
|
||||
await page.getByRole('button', { name: 'Invite' }).click();
|
||||
|
||||
await page.getByRole('tab', { name: 'Pending' }).click();
|
||||
await expect(page.getByText(member.email)).toBeVisible();
|
||||
|
||||
// Joining is never blocked: the member accepts the invite without 2FA.
|
||||
await apiSignout({ page });
|
||||
await apiSignin({ page, email: member.email, redirectPath: `/settings/organisations` });
|
||||
|
||||
await page.getByRole('button', { name: 'View invites' }).click();
|
||||
await page.getByRole('button', { name: 'Accept' }).click();
|
||||
await expect(page.getByText('Invitation accepted').first()).toBeVisible();
|
||||
|
||||
const membership = await prisma.organisationMember.findFirst({
|
||||
where: {
|
||||
userId: member.id,
|
||||
organisationId: organisation.id,
|
||||
},
|
||||
});
|
||||
|
||||
expect(membership).not.toBeNull();
|
||||
|
||||
// Access, however, is blocked immediately (0-day grace): the org context
|
||||
// renders the 403 screen linking to forced enrolment.
|
||||
await page.goto(`/o/${organisation.url}`);
|
||||
await expect(page.getByRole('heading', { name: 'Two-factor authentication required' })).toBeVisible();
|
||||
await expect(page.getByText('403 Forbidden')).toBeVisible();
|
||||
|
||||
const enrolmentLink = page.getByRole('link', { name: 'Set up two-factor authentication' });
|
||||
await expect(enrolmentLink).toBeVisible();
|
||||
await expect(enrolmentLink).toHaveAttribute('href', /\/onboarding\/2fa\?returnTo=/);
|
||||
|
||||
// Team contexts resolve to the owning organisation and are blocked too.
|
||||
await page.goto(`/t/${team.url}/documents`);
|
||||
await expect(page.getByRole('heading', { name: 'Two-factor authentication required' })).toBeVisible();
|
||||
|
||||
// The security boundary: a blocked org-scoped tRPC call returns 403.
|
||||
const blockedResponse = await page
|
||||
.context()
|
||||
.request.get(
|
||||
`${NEXT_PUBLIC_WEBAPP_URL()}/api/trpc/organisation.get?input=${encodeURIComponent(
|
||||
JSON.stringify({ json: { organisationReference: organisation.url } }),
|
||||
)}`,
|
||||
);
|
||||
|
||||
expect(blockedResponse.status()).toBe(403);
|
||||
|
||||
// The rest of the app stays usable: the member's own organisation is
|
||||
// unaffected.
|
||||
await page.goto(`/settings/organisations`);
|
||||
await expect(page.getByText('Two-factor authentication required')).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('[ORGANISATIONS]: member with satisfied 2FA is unaffected by enforcement', async ({ page }) => {
|
||||
const { organisation, team } = await seedUser({ isPersonalOrganisation: false });
|
||||
|
||||
const memberEmail = `member-${nanoid()}@test.documenso.com`;
|
||||
|
||||
const [member] = await seedOrganisationMembers({
|
||||
members: [
|
||||
{
|
||||
email: memberEmail,
|
||||
name: 'Member 2FA',
|
||||
organisationRole: 'MEMBER',
|
||||
},
|
||||
],
|
||||
organisationId: organisation.id,
|
||||
});
|
||||
|
||||
const { secret } = await seedUserTwoFactorAuthentication({ userId: member.id });
|
||||
|
||||
await seedOrganisationTwoFactorEnforcement({
|
||||
organisationId: organisation.id,
|
||||
twoFactorGracePeriodDays: 0,
|
||||
});
|
||||
|
||||
await waitForStableTotpWindow();
|
||||
|
||||
// Signing in with a valid TOTP code marks the session as second-factor
|
||||
// verified, which satisfies enforcement.
|
||||
await apiSignin({
|
||||
page,
|
||||
email: memberEmail,
|
||||
totpCode: await generateTotpCode({ secret }),
|
||||
redirectPath: `/o/${organisation.url}`,
|
||||
});
|
||||
|
||||
await expect(page.getByText(team.name).first()).toBeVisible();
|
||||
await expect(page.getByText('Two-factor authentication required')).not.toBeVisible();
|
||||
|
||||
await page.goto(`/t/${team.url}/documents`);
|
||||
await expect(page.getByText('Two-factor authentication required')).not.toBeVisible();
|
||||
|
||||
const allowedResponse = await page
|
||||
.context()
|
||||
.request.get(
|
||||
`${NEXT_PUBLIC_WEBAPP_URL()}/api/trpc/organisation.get?input=${encodeURIComponent(
|
||||
JSON.stringify({ json: { organisationReference: organisation.url } }),
|
||||
)}`,
|
||||
);
|
||||
|
||||
expect(allowedResponse.status()).toBe(200);
|
||||
});
|
||||
|
||||
test('[ORGANISATIONS]: blocked member can leave the organisation', async ({ page }) => {
|
||||
const { organisation } = await seedUser({ isPersonalOrganisation: false });
|
||||
|
||||
const memberEmail = `member-${nanoid()}@test.documenso.com`;
|
||||
|
||||
const [member] = await seedOrganisationMembers({
|
||||
members: [
|
||||
{
|
||||
email: memberEmail,
|
||||
name: 'Blocked Member',
|
||||
organisationRole: 'MEMBER',
|
||||
},
|
||||
],
|
||||
organisationId: organisation.id,
|
||||
});
|
||||
|
||||
await seedOrganisationTwoFactorEnforcement({
|
||||
organisationId: organisation.id,
|
||||
twoFactorGracePeriodDays: 0,
|
||||
});
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: memberEmail,
|
||||
redirectPath: `/o/${organisation.url}`,
|
||||
});
|
||||
|
||||
// Confirm the member is blocked from the organisation context.
|
||||
await expect(page.getByRole('heading', { name: 'Two-factor authentication required' })).toBeVisible();
|
||||
|
||||
// A member must always be able to walk away: `organisation.leave` is on
|
||||
// the remediation allow-list, so leaving works while blocked.
|
||||
await page.goto('/settings/organisations');
|
||||
await page.getByRole('button', { name: 'Leave' }).click();
|
||||
await page.getByRole('button', { name: 'Leave' }).click();
|
||||
|
||||
await expect(page.getByText('You have successfully left this organisation').first()).toBeVisible();
|
||||
await expect(page.getByText('No results found').first()).toBeVisible();
|
||||
|
||||
const membership = await prisma.organisationMember.findFirst({
|
||||
where: {
|
||||
userId: member.id,
|
||||
organisationId: organisation.id,
|
||||
},
|
||||
});
|
||||
|
||||
expect(membership).toBeNull();
|
||||
});
|
||||
|
||||
test('[ORGANISATIONS]: admin with satisfied 2FA can enable and persist enforcement settings', async ({ page }) => {
|
||||
const { user: owner, organisation } = await seedUser({ isPersonalOrganisation: false });
|
||||
|
||||
const { secret } = await seedUserTwoFactorAuthentication({ userId: owner.id });
|
||||
|
||||
await waitForStableTotpWindow();
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: owner.email,
|
||||
totpCode: await generateTotpCode({ secret }),
|
||||
redirectPath: `/o/${organisation.url}/settings/general`,
|
||||
});
|
||||
|
||||
const requireSwitch = page.getByRole('switch', { name: 'Require two-factor authentication' });
|
||||
|
||||
await expect(requireSwitch).toBeVisible();
|
||||
await expect(requireSwitch).not.toBeChecked();
|
||||
|
||||
await requireSwitch.click();
|
||||
await page.getByLabel('Grace period (days)').fill('30');
|
||||
await page.getByRole('button', { name: 'Update' }).click();
|
||||
|
||||
await expect(page.getByText('Two-factor enforcement settings updated').first()).toBeVisible();
|
||||
|
||||
// Roundtrip: values persist across a reload.
|
||||
await page.reload();
|
||||
|
||||
await expect(page.getByRole('switch', { name: 'Require two-factor authentication' })).toBeChecked();
|
||||
await expect(page.getByLabel('Grace period (days)')).toHaveValue('30');
|
||||
|
||||
const settings = await prisma.organisation.findFirstOrThrow({
|
||||
where: {
|
||||
id: organisation.id,
|
||||
},
|
||||
include: {
|
||||
organisationGlobalSettings: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(settings.organisationGlobalSettings.twoFactorRequired).toBe(true);
|
||||
expect(settings.organisationGlobalSettings.twoFactorGracePeriodDays).toBe(30);
|
||||
expect(settings.organisationGlobalSettings.twoFactorEnforcedFrom).not.toBeNull();
|
||||
});
|
||||
|
||||
test('[ORGANISATIONS]: admin without 2FA cannot enable enforcement', async ({ page }) => {
|
||||
const { user: owner, organisation } = await seedUser({ isPersonalOrganisation: false });
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: owner.email,
|
||||
redirectPath: `/o/${organisation.url}/settings/general`,
|
||||
});
|
||||
|
||||
const requireSwitch = page.getByRole('switch', { name: 'Require two-factor authentication' });
|
||||
|
||||
await expect(requireSwitch).toBeVisible();
|
||||
|
||||
await requireSwitch.click();
|
||||
await page.getByRole('button', { name: 'Update' }).click();
|
||||
|
||||
// Enable-time guard: the acting admin must already satisfy the policy
|
||||
// being enabled.
|
||||
await expect(
|
||||
page.getByText('You must have two-factor authentication enabled and verified on this session').first(),
|
||||
).toBeVisible();
|
||||
|
||||
const settings = await prisma.organisation.findFirstOrThrow({
|
||||
where: {
|
||||
id: organisation.id,
|
||||
},
|
||||
include: {
|
||||
organisationGlobalSettings: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(settings.organisationGlobalSettings.twoFactorRequired).toBe(false);
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { UserSecurityAuditLogType } from '@prisma/client';
|
||||
|
||||
import { checkSessionValid } from '../fixtures/authentication';
|
||||
import { seedUserTwoFactorAuthentication } from '../fixtures/two-factor';
|
||||
|
||||
test('[USER] backup code sign-in resets 2FA and lands on the re-enrolment page', async ({ page }) => {
|
||||
const { user } = await seedUser();
|
||||
|
||||
const { backupCodes } = await seedUserTwoFactorAuthentication({ userId: user.id });
|
||||
|
||||
await page.goto('/signin');
|
||||
await page.getByLabel('Email').fill(user.email);
|
||||
await page.getByLabel('Password', { exact: true }).fill('password');
|
||||
await page.getByRole('button', { name: 'Sign In' }).click();
|
||||
|
||||
// The missing second factor opens the 2FA dialog.
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(dialog.getByText('Two-Factor Authentication')).toBeVisible();
|
||||
|
||||
await dialog.getByRole('button', { name: 'Use Backup Code' }).click();
|
||||
await dialog.getByLabel('Backup Code').fill(backupCodes[0]);
|
||||
await dialog.getByRole('button', { name: 'Sign In' }).click();
|
||||
|
||||
// Backup codes are recovery, not sign-in: the server resets 2FA and the
|
||||
// client is redirected to the forced re-enrolment page.
|
||||
await page.waitForURL(/\/onboarding\/2fa/);
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Two-factor authentication' })).toBeVisible();
|
||||
|
||||
// Enforcement is not active for this user, so the page offers an explicit
|
||||
// skip link instead of auto-redirecting away.
|
||||
await expect(page.getByRole('link', { name: 'Skip for now' })).toBeVisible();
|
||||
|
||||
// The recovery sign-in still authorizes a session.
|
||||
expect(await checkSessionValid(page)).toBe(true);
|
||||
|
||||
// The reset is atomic: 2FA disabled, secret and backup codes cleared.
|
||||
const updatedUser = await prisma.user.findFirstOrThrow({
|
||||
where: {
|
||||
id: user.id,
|
||||
},
|
||||
});
|
||||
|
||||
expect(updatedUser.twoFactorEnabled).toBe(false);
|
||||
expect(updatedUser.twoFactorSecret).toBeNull();
|
||||
expect(updatedUser.twoFactorBackupCodes).toBeNull();
|
||||
|
||||
// The recovery reset is audit-logged.
|
||||
const auditLog = await prisma.userSecurityAuditLog.findFirst({
|
||||
where: {
|
||||
userId: user.id,
|
||||
type: UserSecurityAuditLogType.AUTH_2FA_DISABLE,
|
||||
},
|
||||
});
|
||||
|
||||
expect(auditLog).not.toBeNull();
|
||||
|
||||
// A consumed backup code cannot be used to sign in again: 2FA is no longer
|
||||
// enabled, so a plain password sign-in succeeds and re-enrolment starts
|
||||
// from scratch.
|
||||
});
|
||||
@@ -5,13 +5,14 @@ import { hc } from 'hono/client';
|
||||
import superjson from 'superjson';
|
||||
|
||||
import type { AuthAppType } from '../server';
|
||||
import type { SessionValidationResult } from '../server/lib/session/session';
|
||||
import type { PartialAccount } from '../server/lib/utils/get-accounts';
|
||||
import type { ActiveSession } from '../server/lib/utils/get-session';
|
||||
import { handleSignInRedirect } from '../server/lib/utils/redirect';
|
||||
import type { TSessionJsonResponse } from '../server/routes/session';
|
||||
import type {
|
||||
TDisableTwoFactorRequestSchema,
|
||||
TEnableTwoFactorRequestSchema,
|
||||
TVerifyTwoFactorChallengeRequestSchema,
|
||||
TViewTwoFactorRecoveryCodesRequestSchema,
|
||||
} from '../server/routes/two-factor.types';
|
||||
import type {
|
||||
@@ -29,9 +30,8 @@ type TEmailPasswordSignin = InferRequestType<AuthClientType['email-password']['a
|
||||
redirectPath?: string;
|
||||
};
|
||||
|
||||
type TPasskeySignin = InferRequestType<AuthClientType['passkey']['authorize']['$post']>['json'] & {
|
||||
redirectPath?: string;
|
||||
};
|
||||
// `redirectPath` is part of the request schema and validated server-side.
|
||||
type TPasskeySignin = InferRequestType<AuthClientType['passkey']['authorize']['$post']>['json'];
|
||||
|
||||
export class AuthClient {
|
||||
public client: AuthClientType;
|
||||
@@ -71,7 +71,7 @@ export class AuthClient {
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
return superjson.deserialize<SessionValidationResult>(result);
|
||||
return superjson.deserialize<TSessionJsonResponse>(result);
|
||||
}
|
||||
|
||||
public async getSessions() {
|
||||
@@ -146,6 +146,20 @@ export class AuthClient {
|
||||
throw AppError.parseError(error);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
// The server overrides the redirect when the sign-in requires a
|
||||
// follow-up page, e.g. a backup-code sign-in resets 2FA and lands on
|
||||
// the re-enrolment page. The caller's redirect path is preserved as
|
||||
// `returnTo` (validated by the target page before use).
|
||||
if (result.redirectPath) {
|
||||
const returnTo = data.redirectPath ? `?returnTo=${encodeURIComponent(data.redirectPath)}` : '';
|
||||
|
||||
handleSignInRedirect(`${result.redirectPath}${returnTo}`);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
handleSignInRedirect(data.redirectPath);
|
||||
},
|
||||
|
||||
@@ -245,6 +259,8 @@ export class AuthClient {
|
||||
|
||||
throw AppError.parseError(error);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
},
|
||||
viewRecoveryCodes: async (data: TViewTwoFactorRecoveryCodesRequestSchema) => {
|
||||
const response = await this.client['two-factor']['view-recovery-codes'].$post({ json: data });
|
||||
@@ -257,6 +273,51 @@ export class AuthClient {
|
||||
|
||||
return response.json();
|
||||
},
|
||||
|
||||
/**
|
||||
* Check whether a pending 2FA challenge exists for this browser, so the
|
||||
* /2fa-challenge page can bounce back to sign-in when there is none.
|
||||
*/
|
||||
getChallenge: async () => {
|
||||
const response = await this.client['two-factor'].challenge.$get();
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
|
||||
throw AppError.parseError(error);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
},
|
||||
|
||||
/**
|
||||
* Verify the second factor for a pending 2FA challenge. Fetches a fresh
|
||||
* CSRF token first since the challenge page is reached via a 302
|
||||
* redirect, not the sign-in form.
|
||||
*/
|
||||
verifyChallenge: async (data: Omit<TVerifyTwoFactorChallengeRequestSchema, 'csrfToken'>) => {
|
||||
const { csrfToken } = await this.client.csrf.$get().then(async (res) => res.json());
|
||||
|
||||
const response = await this.client['two-factor'].challenge.$post({
|
||||
json: {
|
||||
...data,
|
||||
csrfToken,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
|
||||
throw AppError.parseError(error);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
// The server returns the validated redirect path stored when the
|
||||
// challenge was created (or the re-enrolment page after a backup-code
|
||||
// recovery). Navigation goes through the same-origin redirect helper.
|
||||
handleSignInRedirect(result.redirectPath);
|
||||
},
|
||||
};
|
||||
|
||||
public passkey = {
|
||||
@@ -269,7 +330,11 @@ export class AuthClient {
|
||||
throw AppError.parseError(error);
|
||||
}
|
||||
|
||||
handleSignInRedirect(data.redirectPath);
|
||||
const result = await response.json();
|
||||
|
||||
// The server validates the requested redirect path and echoes back a
|
||||
// safe same-origin path (falling back to `/`).
|
||||
handleSignInRedirect(result.url);
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -17,6 +17,9 @@ export const AuthenticationErrorCode = {
|
||||
// TwoFactorMissingSecret: 'TWO_FACTOR_MISSING_SECRET',
|
||||
// TwoFactorMissingCredentials: 'TWO_FACTOR_MISSING_CREDENTIALS',
|
||||
InvalidTwoFactorCode: 'INVALID_TWO_FACTOR_CODE',
|
||||
// A pending 2FA challenge is missing, expired, or exhausted — the client
|
||||
// must restart the sign-in flow.
|
||||
TwoFactorChallengeExpired: 'TWO_FACTOR_CHALLENGE_EXPIRED',
|
||||
SigninDisabled: 'SIGNIN_DISABLED',
|
||||
SignupDisabled: 'SIGNUP_DISABLED',
|
||||
SignupDisposableEmail: 'SIGNUP_DISPOSABLE_EMAIL',
|
||||
|
||||
@@ -11,7 +11,7 @@ import { generateSessionToken } from './session';
|
||||
export const sessionCookieName = formatSecureCookieName('sessionId');
|
||||
export const csrfCookieName = formatSecureCookieName('csrfToken');
|
||||
|
||||
const getAuthSecret = () => {
|
||||
export const getAuthSecret = () => {
|
||||
const authSecret = env('NEXTAUTH_SECRET');
|
||||
|
||||
if (!authSecret) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import type { TSessionAuthMethod } from '@documenso/lib/types/session-auth-method';
|
||||
import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { sha256 } from '@oslojs/crypto/sha2';
|
||||
@@ -14,7 +15,16 @@ import { AUTH_SESSION_LIFETIME } from '../../config';
|
||||
*/
|
||||
export type SessionUser = Pick<
|
||||
User,
|
||||
'id' | 'name' | 'email' | 'emailVerified' | 'avatarImageId' | 'twoFactorEnabled' | 'roles' | 'signature' | 'disabled'
|
||||
| 'id'
|
||||
| 'name'
|
||||
| 'email'
|
||||
| 'emailVerified'
|
||||
| 'avatarImageId'
|
||||
| 'twoFactorEnabled'
|
||||
| 'twoFactorGraceStartedAt'
|
||||
| 'roles'
|
||||
| 'signature'
|
||||
| 'disabled'
|
||||
>;
|
||||
|
||||
export type SessionValidationResult =
|
||||
@@ -35,7 +45,25 @@ export const generateSessionToken = (): string => {
|
||||
return token;
|
||||
};
|
||||
|
||||
export const createSession = async (token: string, userId: number, metadata: RequestMetadata): Promise<Session> => {
|
||||
export type CreateSessionOptions = {
|
||||
/**
|
||||
* The method used to authenticate this session.
|
||||
*/
|
||||
authMethod: TSessionAuthMethod;
|
||||
|
||||
/**
|
||||
* Whether a second factor was passed during sign-in (TOTP/backup challenge
|
||||
* or UV passkey).
|
||||
*/
|
||||
twoFactorVerified: boolean;
|
||||
};
|
||||
|
||||
export const createSession = async (
|
||||
token: string,
|
||||
userId: number,
|
||||
metadata: RequestMetadata,
|
||||
options: CreateSessionOptions,
|
||||
): Promise<Session> => {
|
||||
const hashedSessionId = encodeHexLowerCase(sha256(new TextEncoder().encode(token)));
|
||||
|
||||
const session: Session = {
|
||||
@@ -47,6 +75,8 @@ export const createSession = async (token: string, userId: number, metadata: Req
|
||||
expiresAt: new Date(Date.now() + AUTH_SESSION_LIFETIME),
|
||||
ipAddress: metadata.ipAddress ?? null,
|
||||
userAgent: metadata.userAgent ?? null,
|
||||
authMethod: options.authMethod,
|
||||
twoFactorVerified: options.twoFactorVerified,
|
||||
};
|
||||
|
||||
await prisma.session.create({
|
||||
@@ -84,6 +114,7 @@ export const validateSessionToken = async (token: string): Promise<SessionValida
|
||||
emailVerified: true,
|
||||
avatarImageId: true,
|
||||
twoFactorEnabled: true,
|
||||
twoFactorGraceStartedAt: true,
|
||||
roles: true,
|
||||
signature: true,
|
||||
disabled: true,
|
||||
|
||||
@@ -1,12 +1,26 @@
|
||||
import { assertUserNotDisabledById } from '@documenso/lib/server-only/user/assert-user-not-disabled';
|
||||
import type { TSessionAuthMethod } from '@documenso/lib/types/session-auth-method';
|
||||
import type { Context } from 'hono';
|
||||
|
||||
import type { HonoAuthContext } from '../../types/context';
|
||||
import { createSession, generateSessionToken } from '../session/session';
|
||||
import { setSessionCookie } from '../session/session-cookies';
|
||||
import { sweepPendingTwoFactorChallenge } from './two-factor-challenge';
|
||||
|
||||
type AuthorizeUser = {
|
||||
userId: number;
|
||||
|
||||
/**
|
||||
* The method the user authenticated with. Every sign-in route must pass
|
||||
* this explicitly.
|
||||
*/
|
||||
authMethod: TSessionAuthMethod;
|
||||
|
||||
/**
|
||||
* Whether a second factor was passed during this sign-in (inline TOTP on
|
||||
* email/password login, or a UV passkey).
|
||||
*/
|
||||
twoFactorVerified: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -20,11 +34,20 @@ type AuthorizeUser = {
|
||||
export const onAuthorize = async (user: AuthorizeUser, c: Context<HonoAuthContext>) => {
|
||||
await assertUserNotDisabledById({ userId: user.userId });
|
||||
|
||||
// Any successful sign-in clears a pending 2FA challenge — an abandoned
|
||||
// challenge must not outlive a login via another route. The challenge
|
||||
// endpoint consumes its own token before calling `onAuthorize`, so this
|
||||
// sweep finds nothing there (no recursion, no double-consumption).
|
||||
await sweepPendingTwoFactorChallenge(c);
|
||||
|
||||
const metadata = c.get('requestMetadata');
|
||||
|
||||
const sessionToken = generateSessionToken();
|
||||
|
||||
await createSession(sessionToken, user.userId, metadata);
|
||||
await createSession(sessionToken, user.userId, metadata, {
|
||||
authMethod: user.authMethod,
|
||||
twoFactorVerified: user.twoFactorVerified,
|
||||
});
|
||||
|
||||
await setSessionCookie(c, sessionToken);
|
||||
};
|
||||
|
||||
@@ -59,6 +59,8 @@ export const getActiveSessions = async (c: Context | Request): Promise<ActiveSes
|
||||
createdAt: true,
|
||||
ipAddress: true,
|
||||
userAgent: true,
|
||||
authMethod: true,
|
||||
twoFactorVerified: true,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -20,6 +20,7 @@ import type { OAuthClientOptions } from '../../config';
|
||||
import { AuthenticationErrorCode } from '../errors/error-codes';
|
||||
import { onAuthorize } from './authorizer';
|
||||
import { getOpenIdConfiguration } from './open-id';
|
||||
import { createTwoFactorChallenge } from './two-factor-challenge';
|
||||
|
||||
type HandleOAuthCallbackUrlOptions = {
|
||||
c: Context;
|
||||
@@ -50,6 +51,7 @@ export const handleOAuthCallbackUrl = async (options: HandleOAuthCallbackUrlOpti
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
twoFactorEnabled: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -57,7 +59,21 @@ export const handleOAuthCallbackUrl = async (options: HandleOAuthCallbackUrlOpti
|
||||
|
||||
// Directly log in user if account already exists.
|
||||
if (existingAccount) {
|
||||
await onAuthorize({ userId: existingAccount.user.id }, c);
|
||||
// A 2FA-enabled user must pass a TOTP/backup challenge before any session
|
||||
// exists — primary (OAuth) auth alone only earns a pending challenge.
|
||||
if (existingAccount.user.twoFactorEnabled) {
|
||||
await createTwoFactorChallenge(c, {
|
||||
userId: existingAccount.user.id,
|
||||
metadata: {
|
||||
redirectPath,
|
||||
authMethod: 'oauth',
|
||||
},
|
||||
});
|
||||
|
||||
return c.redirect('/2fa-challenge', 302);
|
||||
}
|
||||
|
||||
await onAuthorize({ userId: existingAccount.user.id, authMethod: 'oauth', twoFactorVerified: false }, c);
|
||||
|
||||
return c.redirect(redirectPath, 302);
|
||||
}
|
||||
@@ -69,11 +85,37 @@ export const handleOAuthCallbackUrl = async (options: HandleOAuthCallbackUrlOpti
|
||||
select: {
|
||||
id: true,
|
||||
emailVerified: true,
|
||||
twoFactorEnabled: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Handle existing user but no account.
|
||||
if (userWithSameEmail) {
|
||||
// Deferred account linking: when the target user has 2FA enabled, NO
|
||||
// account mutation may happen before the code verifies. The entire link
|
||||
// transaction below is deferred into the challenge metadata `action` and
|
||||
// executed atomically with token consumption after the code passes.
|
||||
//
|
||||
// Access/ID tokens are intentionally NOT stored in the metadata — the
|
||||
// deferred account row is created without them.
|
||||
if (userWithSameEmail.twoFactorEnabled) {
|
||||
await createTwoFactorChallenge(c, {
|
||||
userId: userWithSameEmail.id,
|
||||
metadata: {
|
||||
redirectPath,
|
||||
authMethod: 'oauth',
|
||||
action: {
|
||||
type: 'link-oauth-account',
|
||||
provider: clientOptions.id,
|
||||
providerAccountId: sub,
|
||||
email,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return c.redirect('/2fa-challenge', 302);
|
||||
}
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.account.create({
|
||||
data: {
|
||||
@@ -114,7 +156,7 @@ export const handleOAuthCallbackUrl = async (options: HandleOAuthCallbackUrlOpti
|
||||
}
|
||||
});
|
||||
|
||||
await onAuthorize({ userId: userWithSameEmail.id }, c);
|
||||
await onAuthorize({ userId: userWithSameEmail.id, authMethod: 'oauth', twoFactorVerified: false }, c);
|
||||
|
||||
return c.redirect(redirectPath, 302);
|
||||
}
|
||||
@@ -179,7 +221,7 @@ export const handleOAuthCallbackUrl = async (options: HandleOAuthCallbackUrlOpti
|
||||
console.error(err);
|
||||
});
|
||||
|
||||
await onAuthorize({ userId: createdUser.id }, c);
|
||||
await onAuthorize({ userId: createdUser.id, authMethod: 'oauth', twoFactorVerified: false }, c);
|
||||
|
||||
return c.redirect(redirectPath, 302);
|
||||
};
|
||||
|
||||
@@ -12,6 +12,7 @@ import { AuthenticationErrorCode } from '../errors/error-codes';
|
||||
import { onAuthorize } from './authorizer';
|
||||
import { validateOauth } from './handle-oauth-callback-url';
|
||||
import { getOrganisationAuthenticationPortalOptions } from './organisation-portal';
|
||||
import { createTwoFactorChallenge } from './two-factor-challenge';
|
||||
|
||||
type HandleOAuthOrganisationCallbackUrlOptions = {
|
||||
c: Context;
|
||||
@@ -26,7 +27,7 @@ export const handleOAuthOrganisationCallbackUrl = async (options: HandleOAuthOrg
|
||||
organisationUrl: orgUrl,
|
||||
});
|
||||
|
||||
const { email, name, sub, accessToken, accessTokenExpiresAt, idToken } = await validateOauth({
|
||||
const { email, name, sub } = await validateOauth({
|
||||
c,
|
||||
clientOptions: {
|
||||
...clientOptions,
|
||||
@@ -55,7 +56,21 @@ export const handleOAuthOrganisationCallbackUrl = async (options: HandleOAuthOrg
|
||||
|
||||
// Directly log in user if account already exists.
|
||||
if (existingAccount) {
|
||||
await onAuthorize({ userId: existingAccount.user.id }, c);
|
||||
// A 2FA-enabled user must pass a TOTP/backup challenge before any session
|
||||
// exists — primary (org OIDC) auth alone only earns a pending challenge.
|
||||
if (existingAccount.user.twoFactorEnabled) {
|
||||
await createTwoFactorChallenge(c, {
|
||||
userId: existingAccount.user.id,
|
||||
metadata: {
|
||||
redirectPath: `/o/${orgUrl}`,
|
||||
authMethod: 'oauth',
|
||||
},
|
||||
});
|
||||
|
||||
return c.redirect('/2fa-challenge', 302);
|
||||
}
|
||||
|
||||
await onAuthorize({ userId: existingAccount.user.id, authMethod: 'oauth', twoFactorVerified: false }, c);
|
||||
|
||||
return c.redirect(formatPath(`/o/${orgUrl}`), 302);
|
||||
}
|
||||
@@ -103,16 +118,16 @@ export const handleOAuthOrganisationCallbackUrl = async (options: HandleOAuthOrg
|
||||
});
|
||||
}
|
||||
|
||||
// Note: only the provider subject crosses into the verification token
|
||||
// metadata — access/ID tokens are deliberately not persisted (see
|
||||
// ZOrganisationAccountLinkMetadataSchema).
|
||||
await sendOrganisationAccountLinkConfirmationEmail({
|
||||
type: userToLink.emailVerified ? 'link' : 'create',
|
||||
userId: userToLink.id,
|
||||
organisationId: organisation.id,
|
||||
organisationName: organisation.name,
|
||||
oauthConfig: {
|
||||
accessToken,
|
||||
idToken,
|
||||
providerAccountId: sub,
|
||||
expiresAt: Math.floor(accessTokenExpiresAt.getTime() / 1000),
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,377 @@
|
||||
import { formatSecureCookieName } from '@documenso/lib/constants/auth';
|
||||
import { AppError } from '@documenso/lib/errors/app-error';
|
||||
import type { TTwoFactorChallengeAction, TTwoFactorChallengeMetadata } from '@documenso/lib/types/two-factor-challenge';
|
||||
import { ZTwoFactorChallengeMetadataSchema } from '@documenso/lib/types/two-factor-challenge';
|
||||
import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
||||
import {
|
||||
isChallengeExpired,
|
||||
shouldConsumeChallengeAfterFailure,
|
||||
TWO_FACTOR_CHALLENGE_LIFETIME_MS,
|
||||
TWO_FACTOR_CHALLENGE_MAX_ATTEMPTS,
|
||||
TWO_FACTOR_CHALLENGE_TOKEN_IDENTIFIER,
|
||||
} from '@documenso/lib/utils/two-factor-challenge';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import type { Prisma } from '@prisma/client';
|
||||
import { UserSecurityAuditLogType } from '@prisma/client';
|
||||
import crypto from 'crypto';
|
||||
import type { Context } from 'hono';
|
||||
import { deleteCookie, getSignedCookie, setSignedCookie } from 'hono/cookie';
|
||||
|
||||
import { AuthenticationErrorCode } from '../errors/error-codes';
|
||||
import { getAuthSecret, sessionCookieOptions } from '../session/session-cookies';
|
||||
|
||||
/**
|
||||
* Pending 2FA challenge plumbing for sign-in flows where the second factor
|
||||
* arrives in a later request than primary authentication (OAuth/OIDC
|
||||
* callbacks).
|
||||
*
|
||||
* The challenge is a random token stored in `VerificationToken` plus a signed
|
||||
* HttpOnly cookie carrying that token. The token is NOT a second factor — it
|
||||
* only carries "primary auth passed for user X" across the redirect, since no
|
||||
* session may exist before the TOTP/backup code is verified. Code
|
||||
* verification itself is the same `validateTwoFactorAuthentication` used by
|
||||
* the email/password flow.
|
||||
*/
|
||||
|
||||
const twoFactorChallengeCookieName = formatSecureCookieName('twoFactorChallenge');
|
||||
|
||||
export type PendingTwoFactorChallenge = {
|
||||
id: number;
|
||||
userId: number;
|
||||
attempts: number;
|
||||
metadata: TTwoFactorChallengeMetadata;
|
||||
};
|
||||
|
||||
export type CreateTwoFactorChallengeOptions = {
|
||||
userId: number;
|
||||
metadata: TTwoFactorChallengeMetadata;
|
||||
};
|
||||
|
||||
/**
|
||||
* Issue a pending 2FA challenge for a user whose primary authentication has
|
||||
* succeeded, and attach the signed challenge cookie to the response.
|
||||
*
|
||||
* The metadata is round-tripped through the strict schema so an invalid
|
||||
* redirect path or a payload carrying unexpected keys (e.g. provider tokens)
|
||||
* can never be persisted.
|
||||
*/
|
||||
export const createTwoFactorChallenge = async (c: Context, options: CreateTwoFactorChallengeOptions): Promise<void> => {
|
||||
const metadata = ZTwoFactorChallengeMetadataSchema.parse(options.metadata);
|
||||
|
||||
const token = crypto.randomBytes(32).toString('hex');
|
||||
|
||||
await prisma.verificationToken.create({
|
||||
data: {
|
||||
identifier: TWO_FACTOR_CHALLENGE_TOKEN_IDENTIFIER,
|
||||
token,
|
||||
expires: new Date(Date.now() + TWO_FACTOR_CHALLENGE_LIFETIME_MS),
|
||||
metadata,
|
||||
userId: options.userId,
|
||||
},
|
||||
});
|
||||
|
||||
await setSignedCookie(c, twoFactorChallengeCookieName, token, getAuthSecret(), {
|
||||
...sessionCookieOptions,
|
||||
maxAge: Math.floor(TWO_FACTOR_CHALLENGE_LIFETIME_MS / 1000),
|
||||
});
|
||||
};
|
||||
|
||||
export const clearTwoFactorChallengeCookie = (c: Context): void => {
|
||||
deleteCookie(c, twoFactorChallengeCookieName, sessionCookieOptions);
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve the pending challenge for the current request, if any.
|
||||
*
|
||||
* Expired, exhausted, or malformed challenges are consumed and the cookie is
|
||||
* cleared — callers uniformly receive `null` and should tell the client to
|
||||
* restart sign-in.
|
||||
*/
|
||||
export const getPendingTwoFactorChallenge = async (c: Context): Promise<PendingTwoFactorChallenge | null> => {
|
||||
const token = await getSignedCookie(c, getAuthSecret(), twoFactorChallengeCookieName);
|
||||
|
||||
if (!token) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const row = await prisma.verificationToken.findFirst({
|
||||
where: {
|
||||
token,
|
||||
identifier: TWO_FACTOR_CHALLENGE_TOKEN_IDENTIFIER,
|
||||
},
|
||||
});
|
||||
|
||||
if (!row) {
|
||||
clearTwoFactorChallengeCookie(c);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
const metadataResult = ZTwoFactorChallengeMetadataSchema.safeParse(row.metadata);
|
||||
|
||||
const isUsable =
|
||||
metadataResult.success &&
|
||||
!isChallengeExpired({ expiresAt: row.expires, now: new Date() }) &&
|
||||
!shouldConsumeChallengeAfterFailure(row.attempts);
|
||||
|
||||
if (!isUsable) {
|
||||
// `deleteMany` so a concurrent consumption is a no-op instead of a P2025.
|
||||
await prisma.verificationToken.deleteMany({
|
||||
where: {
|
||||
id: row.id,
|
||||
},
|
||||
});
|
||||
|
||||
clearTwoFactorChallengeCookie(c);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
userId: row.userId,
|
||||
attempts: row.attempts,
|
||||
metadata: metadataResult.data,
|
||||
};
|
||||
};
|
||||
|
||||
export type RecordTwoFactorChallengeFailureOptions = {
|
||||
challenge: PendingTwoFactorChallenge;
|
||||
requestMetadata: RequestMetadata;
|
||||
};
|
||||
|
||||
/**
|
||||
* Record a failed code attempt against a pending challenge.
|
||||
*
|
||||
* Failed codes do not consume the token but atomically increment its attempt
|
||||
* counter. The rate limiter fails open on DB errors, so this counter is the
|
||||
* hard bound on guesses per challenge — once it reaches the cap the challenge
|
||||
* is consumed and sign-in must restart.
|
||||
*/
|
||||
export const recordTwoFactorChallengeFailure = async (
|
||||
c: Context,
|
||||
{ challenge, requestMetadata }: RecordTwoFactorChallengeFailureOptions,
|
||||
): Promise<{ isExhausted: boolean }> => {
|
||||
// Conditional increment: only counts while under the cap so the counter
|
||||
// cannot be raced past it.
|
||||
const { count } = await prisma.verificationToken.updateMany({
|
||||
where: {
|
||||
id: challenge.id,
|
||||
attempts: {
|
||||
lt: TWO_FACTOR_CHALLENGE_MAX_ATTEMPTS,
|
||||
},
|
||||
},
|
||||
data: {
|
||||
attempts: {
|
||||
increment: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.userSecurityAuditLog.create({
|
||||
data: {
|
||||
userId: challenge.userId,
|
||||
ipAddress: requestMetadata.ipAddress,
|
||||
userAgent: requestMetadata.userAgent,
|
||||
type: UserSecurityAuditLogType.SIGN_IN_2FA_FAIL,
|
||||
},
|
||||
});
|
||||
|
||||
if (count === 0) {
|
||||
// Already at the cap (or deleted) via concurrent requests.
|
||||
await prisma.verificationToken.deleteMany({
|
||||
where: {
|
||||
id: challenge.id,
|
||||
},
|
||||
});
|
||||
|
||||
clearTwoFactorChallengeCookie(c);
|
||||
|
||||
return { isExhausted: true };
|
||||
}
|
||||
|
||||
const updated = await prisma.verificationToken.findFirst({
|
||||
where: {
|
||||
id: challenge.id,
|
||||
},
|
||||
select: {
|
||||
attempts: true,
|
||||
},
|
||||
});
|
||||
|
||||
const isExhausted = shouldConsumeChallengeAfterFailure(updated?.attempts ?? Number.MAX_SAFE_INTEGER);
|
||||
|
||||
if (isExhausted) {
|
||||
await prisma.verificationToken.deleteMany({
|
||||
where: {
|
||||
id: challenge.id,
|
||||
},
|
||||
});
|
||||
|
||||
clearTwoFactorChallengeCookie(c);
|
||||
}
|
||||
|
||||
return { isExhausted };
|
||||
};
|
||||
|
||||
/**
|
||||
* Consume a pending challenge on success.
|
||||
*
|
||||
* Uses `deleteMany` + count check so a concurrent replay of the same
|
||||
* challenge errors cleanly instead of surfacing a P2025 as a 500.
|
||||
*/
|
||||
export const consumeTwoFactorChallenge = async (tx: Prisma.TransactionClient, challengeId: number): Promise<void> => {
|
||||
const { count } = await tx.verificationToken.deleteMany({
|
||||
where: {
|
||||
id: challengeId,
|
||||
},
|
||||
});
|
||||
|
||||
if (count === 0) {
|
||||
throw new AppError(AuthenticationErrorCode.TwoFactorChallengeExpired, {
|
||||
message: 'The two factor challenge has already been consumed.',
|
||||
statusCode: 401,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export type ExecuteTwoFactorChallengeActionOptions = {
|
||||
action: TTwoFactorChallengeAction;
|
||||
userId: number;
|
||||
requestMetadata: RequestMetadata;
|
||||
};
|
||||
|
||||
/**
|
||||
* Execute the deferred OAuth account-link action after the code verified.
|
||||
*
|
||||
* The invariant this preserves: NO account mutation happens before the second
|
||||
* factor passes. The entire link transaction the OAuth callback would have
|
||||
* run inline (account row, email-verification/password clearing, link audit
|
||||
* log) is recreated here, in the same transaction as token consumption,
|
||||
* re-validating that the world has not changed since the callback.
|
||||
*/
|
||||
export const executeTwoFactorChallengeAction = async (
|
||||
tx: Prisma.TransactionClient,
|
||||
{ action, userId, requestMetadata }: ExecuteTwoFactorChallengeActionOptions,
|
||||
): Promise<void> => {
|
||||
const user = await tx.user.findFirst({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
emailVerified: true,
|
||||
disabled: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Re-validate: the target user must still exist and must not be disabled.
|
||||
if (!user || user.disabled) {
|
||||
throw new AppError(AuthenticationErrorCode.AccountDisabled, {
|
||||
message: 'Account is not eligible for linking.',
|
||||
statusCode: 403,
|
||||
});
|
||||
}
|
||||
|
||||
// Re-validate: the email must be unchanged since the OAuth callback — a
|
||||
// changed email means the provider account may no longer belong to this
|
||||
// user.
|
||||
if (user.email !== action.email) {
|
||||
throw new AppError(AuthenticationErrorCode.InvalidRequest, {
|
||||
message: 'Account email has changed since the sign-in was initiated.',
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const existingAccount = await tx.account.findFirst({
|
||||
where: {
|
||||
provider: action.provider,
|
||||
providerAccountId: action.providerAccountId,
|
||||
},
|
||||
select: {
|
||||
userId: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Re-validate: the provider account must not already be linked. Linked to
|
||||
// the same user is an idempotent no-op; linked to another user is a
|
||||
// conflict.
|
||||
if (existingAccount) {
|
||||
if (existingAccount.userId !== user.id) {
|
||||
throw new AppError(AuthenticationErrorCode.InvalidRequest, {
|
||||
message: 'This provider account is already linked to another user.',
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// The deferred account row is created WITHOUT access/ID tokens — they are
|
||||
// intentionally never stored in challenge metadata, so they are not
|
||||
// available here. This is deliberate: challenge metadata sits in the
|
||||
// database on the strength of primary auth alone.
|
||||
await tx.account.create({
|
||||
data: {
|
||||
type: 'oauth',
|
||||
provider: action.provider,
|
||||
providerAccountId: action.providerAccountId,
|
||||
userId: user.id,
|
||||
},
|
||||
});
|
||||
|
||||
await tx.userSecurityAuditLog.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
ipAddress: requestMetadata.ipAddress,
|
||||
userAgent: requestMetadata.userAgent,
|
||||
type: UserSecurityAuditLogType.ACCOUNT_SSO_LINK,
|
||||
},
|
||||
});
|
||||
|
||||
// Mirrors the inline OAuth link path: if the user was unverified, the OAuth
|
||||
// provider has now verified the email, and the password is removed since we
|
||||
// cannot confirm it was set by the real owner of the email.
|
||||
if (!user.emailVerified) {
|
||||
await tx.user.update({
|
||||
where: {
|
||||
id: user.id,
|
||||
},
|
||||
data: {
|
||||
emailVerified: new Date(),
|
||||
password: null,
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Best-effort cleanup used by `onAuthorize`: any successful sign-in clears a
|
||||
* pending challenge cookie and deletes its token — an abandoned challenge
|
||||
* must not outlive a login via another route.
|
||||
*
|
||||
* The challenge endpoint itself consumes its own token BEFORE calling
|
||||
* `onAuthorize`, so this sweep finds nothing to delete there and only clears
|
||||
* the (already superseded) cookie.
|
||||
*/
|
||||
export const sweepPendingTwoFactorChallenge = async (c: Context): Promise<void> => {
|
||||
try {
|
||||
const token = await getSignedCookie(c, getAuthSecret(), twoFactorChallengeCookieName);
|
||||
|
||||
if (!token) {
|
||||
return;
|
||||
}
|
||||
|
||||
await prisma.verificationToken.deleteMany({
|
||||
where: {
|
||||
token,
|
||||
identifier: TWO_FACTOR_CHALLENGE_TOKEN_IDENTIFIER,
|
||||
},
|
||||
});
|
||||
|
||||
clearTwoFactorChallengeCookie(c);
|
||||
} catch {
|
||||
// A failed sweep must never block a successful sign-in.
|
||||
}
|
||||
};
|
||||
@@ -7,12 +7,9 @@ import {
|
||||
import { EMAIL_VERIFICATION_STATE } from '@documenso/lib/constants/email';
|
||||
import { AppError } from '@documenso/lib/errors/app-error';
|
||||
import { jobsClient } from '@documenso/lib/jobs/client';
|
||||
import { disableTwoFactorAuthentication } from '@documenso/lib/server-only/2fa/disable-2fa';
|
||||
import { enableTwoFactorAuthentication } from '@documenso/lib/server-only/2fa/enable-2fa';
|
||||
import { isTwoFactorAuthenticationEnabled } from '@documenso/lib/server-only/2fa/is-2fa-availble';
|
||||
import { setupTwoFactorAuthentication } from '@documenso/lib/server-only/2fa/setup-2fa';
|
||||
import { resetTwoFactorAfterBackupCodeUse } from '@documenso/lib/server-only/2fa/reset-2fa-after-backup-code-use';
|
||||
import { validateTwoFactorAuthentication } from '@documenso/lib/server-only/2fa/validate-2fa';
|
||||
import { viewBackupCodes } from '@documenso/lib/server-only/2fa/view-backup-codes';
|
||||
import { verifyCaptchaToken } from '@documenso/lib/server-only/captcha/verify-captcha';
|
||||
import { rateLimitResponse } from '@documenso/lib/server-only/rate-limit/rate-limit-middleware';
|
||||
import {
|
||||
@@ -25,6 +22,7 @@ import {
|
||||
verifyEmailRateLimit,
|
||||
} from '@documenso/lib/server-only/rate-limit/rate-limits';
|
||||
import { getEmailBlocklistDomains } from '@documenso/lib/server-only/site-settings/get-email-blocklist-domains';
|
||||
import { assertUserNotDisabled } from '@documenso/lib/server-only/user/assert-user-not-disabled';
|
||||
import { createUser } from '@documenso/lib/server-only/user/create-user';
|
||||
import { forgotPassword } from '@documenso/lib/server-only/user/forgot-password';
|
||||
import { getMostRecentEmailVerificationToken } from '@documenso/lib/server-only/user/get-most-recent-email-verification-token';
|
||||
@@ -41,7 +39,6 @@ import { UserSecurityAuditLogType } from '@prisma/client';
|
||||
import { Hono } from 'hono';
|
||||
import { HTTPException } from 'hono/http-exception';
|
||||
import { DateTime } from 'luxon';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { AuthenticationErrorCode } from '../lib/errors/error-codes';
|
||||
import { invalidateSessions } from '../lib/session/session';
|
||||
@@ -136,14 +133,16 @@ export const emailPasswordRoute = new Hono<HonoAuthContext>()
|
||||
|
||||
const is2faEnabled = isTwoFactorAuthenticationEnabled({ user });
|
||||
|
||||
let isBackupCodeRecovery = false;
|
||||
|
||||
if (is2faEnabled) {
|
||||
const isValid = await validateTwoFactorAuthentication({
|
||||
const validationResult = await validateTwoFactorAuthentication({
|
||||
backupCode,
|
||||
totpCode,
|
||||
user,
|
||||
});
|
||||
|
||||
if (!isValid) {
|
||||
if (!validationResult.isValid) {
|
||||
await prisma.userSecurityAuditLog.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
@@ -155,6 +154,8 @@ export const emailPasswordRoute = new Hono<HonoAuthContext>()
|
||||
|
||||
throw new AppError(AuthenticationErrorCode.InvalidTwoFactorCode);
|
||||
}
|
||||
|
||||
isBackupCodeRecovery = validationResult.method === 'backup';
|
||||
}
|
||||
|
||||
if (!user.emailVerified) {
|
||||
@@ -180,11 +181,44 @@ export const emailPasswordRoute = new Hono<HonoAuthContext>()
|
||||
});
|
||||
}
|
||||
|
||||
// A rejected sign-in must never strip 2FA, so every sign-in guard runs
|
||||
// before the recovery reset below: the disabled check here (onAuthorize
|
||||
// re-checks it as defence in depth) and the unverified-email guard above.
|
||||
assertUserNotDisabled(user);
|
||||
|
||||
// Backup codes are recovery, not sign-in: a successful backup-code
|
||||
// sign-in atomically resets the user's 2FA configuration (conditional
|
||||
// update — concurrent uses cannot double-spend) and the client is told to
|
||||
// land on the re-enrolment page.
|
||||
if (isBackupCodeRecovery) {
|
||||
await resetTwoFactorAfterBackupCodeUse({ user, requestMetadata });
|
||||
}
|
||||
|
||||
// The disabled check now lives inside `onAuthorize` so every sign-in path
|
||||
// (password, passkey, OAuth, OIDC) shares the same enforcement.
|
||||
await onAuthorize({ userId: user.id }, c);
|
||||
//
|
||||
// If 2FA is enabled we only reach this point after the inline TOTP/backup
|
||||
// validation above has passed, so the session counts as second-factor
|
||||
// verified. A backup code IS a second factor, so recovery sign-ins are
|
||||
// verified too.
|
||||
await onAuthorize(
|
||||
{
|
||||
userId: user.id,
|
||||
authMethod: 'email-password',
|
||||
twoFactorVerified: is2faEnabled,
|
||||
},
|
||||
c,
|
||||
);
|
||||
|
||||
return c.text('', 201);
|
||||
return c.json(
|
||||
{
|
||||
// Non-null when the server needs the client to land somewhere
|
||||
// specific instead of its own redirect path. Currently only the
|
||||
// post-recovery re-enrolment page.
|
||||
redirectPath: isBackupCodeRecovery ? '/onboarding/2fa' : null,
|
||||
},
|
||||
201,
|
||||
);
|
||||
})
|
||||
/**
|
||||
* Signup endpoint.
|
||||
@@ -332,7 +366,7 @@ export const emailPasswordRoute = new Hono<HonoAuthContext>()
|
||||
|
||||
// If email is verified, automatically authenticate user.
|
||||
if (state === EMAIL_VERIFICATION_STATE.VERIFIED && userId !== null) {
|
||||
await onAuthorize({ userId }, c);
|
||||
await onAuthorize({ userId, authMethod: 'email-password', twoFactorVerified: false }, c);
|
||||
}
|
||||
|
||||
return c.json({
|
||||
@@ -470,156 +504,8 @@ export const emailPasswordRoute = new Hono<HonoAuthContext>()
|
||||
}
|
||||
|
||||
return c.text('OK', 201);
|
||||
})
|
||||
/**
|
||||
* Setup two factor authentication.
|
||||
*/
|
||||
.post('/2fa/setup', async (c) => {
|
||||
const { user } = await getSession(c);
|
||||
});
|
||||
|
||||
const result = await setupTwoFactorAuthentication({
|
||||
user,
|
||||
});
|
||||
|
||||
return c.json({
|
||||
success: true,
|
||||
secret: result.secret,
|
||||
uri: result.uri,
|
||||
});
|
||||
})
|
||||
/**
|
||||
* Enable two factor authentication.
|
||||
*/
|
||||
.post(
|
||||
'/2fa/enable',
|
||||
sValidator(
|
||||
'json',
|
||||
z.object({
|
||||
code: z.string(),
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const requestMetadata = c.get('requestMetadata');
|
||||
|
||||
const { user: sessionUser } = await getSession(c);
|
||||
|
||||
const user = await prisma.user.findFirst({
|
||||
where: {
|
||||
id: sessionUser.id,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
twoFactorEnabled: true,
|
||||
twoFactorSecret: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new AppError(AuthenticationErrorCode.InvalidRequest);
|
||||
}
|
||||
|
||||
const { code } = c.req.valid('json');
|
||||
|
||||
const result = await enableTwoFactorAuthentication({
|
||||
user,
|
||||
code,
|
||||
requestMetadata,
|
||||
});
|
||||
|
||||
return c.json({
|
||||
success: true,
|
||||
recoveryCodes: result.recoveryCodes,
|
||||
});
|
||||
},
|
||||
)
|
||||
/**
|
||||
* Disable two factor authentication.
|
||||
*/
|
||||
.post(
|
||||
'/2fa/disable',
|
||||
sValidator(
|
||||
'json',
|
||||
z.object({
|
||||
totpCode: z.string().trim().optional(),
|
||||
backupCode: z.string().trim().optional(),
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const requestMetadata = c.get('requestMetadata');
|
||||
|
||||
const { user: sessionUser } = await getSession(c);
|
||||
|
||||
const user = await prisma.user.findFirst({
|
||||
where: {
|
||||
id: sessionUser.id,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
twoFactorEnabled: true,
|
||||
twoFactorSecret: true,
|
||||
twoFactorBackupCodes: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new AppError(AuthenticationErrorCode.InvalidRequest);
|
||||
}
|
||||
|
||||
const { totpCode, backupCode } = c.req.valid('json');
|
||||
|
||||
await disableTwoFactorAuthentication({
|
||||
user,
|
||||
totpCode,
|
||||
backupCode,
|
||||
requestMetadata,
|
||||
});
|
||||
|
||||
return c.text('OK', 201);
|
||||
},
|
||||
)
|
||||
/**
|
||||
* View backup codes.
|
||||
*/
|
||||
.post(
|
||||
'/2fa/view-recovery-codes',
|
||||
sValidator(
|
||||
'json',
|
||||
z.object({
|
||||
token: z.string(),
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const { user: sessionUser } = await getSession(c);
|
||||
|
||||
const user = await prisma.user.findFirst({
|
||||
where: {
|
||||
id: sessionUser.id,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
twoFactorEnabled: true,
|
||||
twoFactorSecret: true,
|
||||
twoFactorBackupCodes: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new AppError(AuthenticationErrorCode.InvalidRequest);
|
||||
}
|
||||
|
||||
const { token } = c.req.valid('json');
|
||||
|
||||
const backupCodes = await viewBackupCodes({
|
||||
user,
|
||||
token,
|
||||
});
|
||||
|
||||
return c.json({
|
||||
success: true,
|
||||
backupCodes,
|
||||
});
|
||||
},
|
||||
);
|
||||
// Note: The duplicated `/2fa/*` endpoints previously mounted here have been
|
||||
// consolidated into the `/two-factor` route (`./two-factor.ts`), which is the
|
||||
// only set of 2FA endpoints the auth client calls.
|
||||
|
||||
@@ -6,6 +6,7 @@ import { legacyServiceAccountEmail } from '@documenso/lib/server-only/user/servi
|
||||
import type { TAuthenticationResponseJSONSchema } from '@documenso/lib/types/webauthn';
|
||||
import { ZAuthenticationResponseJSONSchema } from '@documenso/lib/types/webauthn';
|
||||
import { getAuthenticatorOptions } from '@documenso/lib/utils/authenticator';
|
||||
import { isValidReturnTo, normalizeReturnTo } from '@documenso/lib/utils/is-valid-return-to';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { sValidator } from '@hono/standard-validator';
|
||||
import { UserSecurityAuditLogType } from '@prisma/client';
|
||||
@@ -37,7 +38,7 @@ export const passkeyRoute = new Hono<HonoAuthContext>()
|
||||
});
|
||||
}
|
||||
|
||||
const { csrfToken, credential } = c.req.valid('json');
|
||||
const { csrfToken, credential, redirectPath } = c.req.valid('json');
|
||||
|
||||
if (typeof csrfToken !== 'string' || csrfToken.length === 0) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST);
|
||||
@@ -104,6 +105,12 @@ export const passkeyRoute = new Hono<HonoAuthContext>()
|
||||
expectedChallenge: challengeToken.token,
|
||||
expectedOrigin: origin,
|
||||
expectedRPID: rpId,
|
||||
// The library defaults this to true — stated explicitly because the
|
||||
// resulting session counts as second-factor verified, which is only
|
||||
// sound if the authenticator performed user verification (PIN or
|
||||
// biometric). See the matching `userVerification: 'required'` in
|
||||
// `create-passkey-signin-options.ts`.
|
||||
requireUserVerification: true,
|
||||
credential: {
|
||||
id: isoBase64URL.fromBuffer(passkey.credentialId),
|
||||
publicKey: new Uint8Array(passkey.credentialPublicKey),
|
||||
@@ -134,11 +141,17 @@ export const passkeyRoute = new Hono<HonoAuthContext>()
|
||||
},
|
||||
});
|
||||
|
||||
await onAuthorize({ userId: user.id }, c);
|
||||
// A passkey is a trusted second factor (user verification enforced
|
||||
// above), so the session counts as second-factor verified.
|
||||
await onAuthorize({ userId: user.id, authMethod: 'passkey', twoFactorVerified: true }, c);
|
||||
|
||||
// Honor the client's redirect path only when it is a valid same-origin
|
||||
// path.
|
||||
const url = isValidReturnTo(redirectPath) ? (normalizeReturnTo(redirectPath) ?? '/') : '/';
|
||||
|
||||
return c.json(
|
||||
{
|
||||
url: '/',
|
||||
url,
|
||||
},
|
||||
200,
|
||||
);
|
||||
|
||||
@@ -1,9 +1,20 @@
|
||||
import { getTwoFactorEnforcementStatus } from '@documenso/lib/server-only/2fa/get-two-factor-enforcement-status';
|
||||
import type { TTwoFactorEnforcementStatus } from '@documenso/lib/utils/two-factor';
|
||||
import { Hono } from 'hono';
|
||||
import superjson from 'superjson';
|
||||
|
||||
import type { SessionValidationResult } from '../lib/session/session';
|
||||
import { getActiveSessions, getOptionalSession } from '../lib/utils/get-session';
|
||||
|
||||
/**
|
||||
* The payload consumed by the client session provider. The instance 2FA
|
||||
* enforcement status rides along with the session so the client can expose it
|
||||
* without any extra queries.
|
||||
*/
|
||||
export type TSessionJsonResponse = SessionValidationResult & {
|
||||
twoFactorEnforcement: TTwoFactorEnforcementStatus;
|
||||
};
|
||||
|
||||
export const sessionRoute = new Hono()
|
||||
.get('/session', async (c) => {
|
||||
const session: SessionValidationResult = await getOptionalSession(c);
|
||||
@@ -18,5 +29,11 @@ export const sessionRoute = new Hono()
|
||||
.get('/session-json', async (c) => {
|
||||
const session: SessionValidationResult = await getOptionalSession(c);
|
||||
|
||||
return c.json(superjson.serialize(session));
|
||||
const twoFactorEnforcement: TTwoFactorEnforcementStatus = session.isAuthenticated
|
||||
? await getTwoFactorEnforcementStatus({ user: session.user, session: session.session })
|
||||
: { required: false };
|
||||
|
||||
const response: TSessionJsonResponse = { ...session, twoFactorEnforcement };
|
||||
|
||||
return c.json(superjson.serialize(response));
|
||||
});
|
||||
|
||||
@@ -1,18 +1,36 @@
|
||||
import { AppError } from '@documenso/lib/errors/app-error';
|
||||
import { disableTwoFactorAuthentication } from '@documenso/lib/server-only/2fa/disable-2fa';
|
||||
import { enableTwoFactorAuthentication } from '@documenso/lib/server-only/2fa/enable-2fa';
|
||||
import { resetTwoFactorAfterBackupCodeUse } from '@documenso/lib/server-only/2fa/reset-2fa-after-backup-code-use';
|
||||
import { setupTwoFactorAuthentication } from '@documenso/lib/server-only/2fa/setup-2fa';
|
||||
import { validateTwoFactorAuthentication } from '@documenso/lib/server-only/2fa/validate-2fa';
|
||||
import { viewBackupCodes } from '@documenso/lib/server-only/2fa/view-backup-codes';
|
||||
import { rateLimitResponse } from '@documenso/lib/server-only/rate-limit/rate-limit-middleware';
|
||||
import {
|
||||
twoFactorChallengeIpRateLimit,
|
||||
twoFactorChallengeUserRateLimit,
|
||||
} from '@documenso/lib/server-only/rate-limit/rate-limits';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { sValidator } from '@hono/standard-validator';
|
||||
import { Hono } from 'hono';
|
||||
import { HTTPException } from 'hono/http-exception';
|
||||
|
||||
import { AuthenticationErrorCode } from '../lib/errors/error-codes';
|
||||
import { getCsrfCookie } from '../lib/session/session-cookies';
|
||||
import { onAuthorize } from '../lib/utils/authorizer';
|
||||
import { getSession } from '../lib/utils/get-session';
|
||||
import {
|
||||
clearTwoFactorChallengeCookie,
|
||||
consumeTwoFactorChallenge,
|
||||
executeTwoFactorChallengeAction,
|
||||
getPendingTwoFactorChallenge,
|
||||
recordTwoFactorChallengeFailure,
|
||||
} from '../lib/utils/two-factor-challenge';
|
||||
import type { HonoAuthContext } from '../types/context';
|
||||
import {
|
||||
ZDisableTwoFactorRequestSchema,
|
||||
ZEnableTwoFactorRequestSchema,
|
||||
ZVerifyTwoFactorChallengeRequestSchema,
|
||||
ZViewTwoFactorRecoveryCodesRequestSchema,
|
||||
} from './two-factor.types';
|
||||
|
||||
@@ -40,7 +58,7 @@ export const twoFactorRoute = new Hono<HonoAuthContext>()
|
||||
.post('/enable', sValidator('json', ZEnableTwoFactorRequestSchema), async (c) => {
|
||||
const requestMetadata = c.get('requestMetadata');
|
||||
|
||||
const { user: sessionUser } = await getSession(c);
|
||||
const { user: sessionUser, session } = await getSession(c);
|
||||
|
||||
const user = await prisma.user.findFirst({
|
||||
where: {
|
||||
@@ -63,6 +81,7 @@ export const twoFactorRoute = new Hono<HonoAuthContext>()
|
||||
const result = await enableTwoFactorAuthentication({
|
||||
user,
|
||||
code,
|
||||
sessionId: session.id,
|
||||
requestMetadata,
|
||||
});
|
||||
|
||||
@@ -99,14 +118,24 @@ export const twoFactorRoute = new Hono<HonoAuthContext>()
|
||||
|
||||
const { totpCode, backupCode } = c.req.valid('json');
|
||||
|
||||
await disableTwoFactorAuthentication({
|
||||
const { orgEnforcementApplies } = await disableTwoFactorAuthentication({
|
||||
user,
|
||||
totpCode,
|
||||
backupCode,
|
||||
requestMetadata,
|
||||
});
|
||||
|
||||
return c.text('OK', 201);
|
||||
// `orgEnforcementApplies` lets the client warn that org/team context
|
||||
// access will block at the org 2FA deadline (immediately if already
|
||||
// past). The disable itself is allowed — only instance enforcement
|
||||
// refuses it server-side.
|
||||
return c.json(
|
||||
{
|
||||
success: true,
|
||||
orgEnforcementApplies,
|
||||
},
|
||||
201,
|
||||
);
|
||||
})
|
||||
|
||||
/**
|
||||
@@ -143,4 +172,235 @@ export const twoFactorRoute = new Hono<HonoAuthContext>()
|
||||
success: true,
|
||||
backupCodes,
|
||||
});
|
||||
})
|
||||
|
||||
/**
|
||||
* Lightweight pending-challenge validity check for the /2fa-challenge page.
|
||||
*
|
||||
* Unauthenticated by design — the signed challenge cookie is the proof that
|
||||
* primary authentication passed. Resolving the challenge also garbage
|
||||
* collects expired/exhausted tokens, so an invalid state reports `false`
|
||||
* and the page sends the user back to sign-in.
|
||||
*/
|
||||
.get('/challenge', async (c) => {
|
||||
const requestMetadata = c.get('requestMetadata');
|
||||
|
||||
// IP-based limit before any challenge resolution.
|
||||
const ipLimitResult = await twoFactorChallengeIpRateLimit.check({
|
||||
ip: requestMetadata.ipAddress ?? 'unknown',
|
||||
});
|
||||
|
||||
const ipLimited = rateLimitResponse(c, ipLimitResult);
|
||||
|
||||
if (ipLimited) {
|
||||
throw new HTTPException(429, {
|
||||
res: ipLimited,
|
||||
});
|
||||
}
|
||||
|
||||
const challenge = await getPendingTwoFactorChallenge(c);
|
||||
|
||||
return c.json({
|
||||
valid: challenge !== null,
|
||||
});
|
||||
})
|
||||
|
||||
/**
|
||||
* Verify the second factor for a pending 2FA challenge (OAuth/OIDC
|
||||
* sign-ins where the code arrives in a later request than primary auth).
|
||||
*
|
||||
* Unauthenticated by design: no session may exist before the code is
|
||||
* verified, so the signed HttpOnly challenge cookie is the proof of primary
|
||||
* authentication. The pending token is NOT a second factor itself — it only
|
||||
* carries "primary auth passed for user X" across the OAuth redirect; the
|
||||
* code is verified against the user's stored TOTP secret / backup codes via
|
||||
* `validateTwoFactorAuthentication`, identical to email/password login.
|
||||
*
|
||||
* No captcha here: this is the continuation of a sign-in that already
|
||||
* passed the provider's and our own abuse controls at the primary auth
|
||||
* step.
|
||||
*/
|
||||
.post('/challenge', sValidator('json', ZVerifyTwoFactorChallengeRequestSchema), async (c) => {
|
||||
const requestMetadata = c.get('requestMetadata');
|
||||
|
||||
const { totpCode, backupCode, csrfToken } = c.req.valid('json');
|
||||
|
||||
// IP-based limit BEFORE challenge resolution so hammering without a valid
|
||||
// cookie never reaches the database token lookup.
|
||||
const ipLimitResult = await twoFactorChallengeIpRateLimit.check({
|
||||
ip: requestMetadata.ipAddress ?? 'unknown',
|
||||
});
|
||||
|
||||
const ipLimited = rateLimitResponse(c, ipLimitResult);
|
||||
|
||||
if (ipLimited) {
|
||||
throw new HTTPException(429, {
|
||||
res: ipLimited,
|
||||
});
|
||||
}
|
||||
|
||||
const csrfCookieToken = await getCsrfCookie(c);
|
||||
|
||||
if (!csrfCookieToken || csrfToken !== csrfCookieToken) {
|
||||
throw new AppError(AuthenticationErrorCode.InvalidRequest, {
|
||||
message: 'Invalid CSRF token',
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const challenge = await getPendingTwoFactorChallenge(c);
|
||||
|
||||
if (!challenge) {
|
||||
throw new AppError(AuthenticationErrorCode.TwoFactorChallengeExpired, {
|
||||
message: 'No pending two factor challenge. Restart the sign-in flow.',
|
||||
statusCode: 401,
|
||||
});
|
||||
}
|
||||
|
||||
// Per-user limit only after the cookie resolved to a user.
|
||||
const userLimitResult = await twoFactorChallengeUserRateLimit.check({
|
||||
ip: requestMetadata.ipAddress ?? 'unknown',
|
||||
identifier: `user:${challenge.userId}`,
|
||||
});
|
||||
|
||||
const userLimited = rateLimitResponse(c, userLimitResult);
|
||||
|
||||
if (userLimited) {
|
||||
throw new HTTPException(429, {
|
||||
res: userLimited,
|
||||
});
|
||||
}
|
||||
|
||||
const user = await prisma.user.findFirst({
|
||||
where: {
|
||||
id: challenge.userId,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
disabled: true,
|
||||
twoFactorEnabled: true,
|
||||
twoFactorSecret: true,
|
||||
twoFactorBackupCodes: true,
|
||||
},
|
||||
});
|
||||
|
||||
// The challenge is moot if the user disappeared or no longer has 2FA
|
||||
// enabled — consume it and force a fresh sign-in.
|
||||
if (!user || !user.twoFactorEnabled) {
|
||||
await prisma.verificationToken.deleteMany({
|
||||
where: {
|
||||
id: challenge.id,
|
||||
},
|
||||
});
|
||||
|
||||
clearTwoFactorChallengeCookie(c);
|
||||
|
||||
throw new AppError(AuthenticationErrorCode.TwoFactorChallengeExpired, {
|
||||
message: 'The two factor challenge is no longer valid. Restart the sign-in flow.',
|
||||
statusCode: 401,
|
||||
});
|
||||
}
|
||||
|
||||
// A rejected sign-in must never mutate the account, so a disabled user is
|
||||
// refused BEFORE code validation — otherwise a valid backup code would
|
||||
// run the recovery reset (stripping 2FA) even though `onAuthorize` would
|
||||
// refuse the session afterwards. The challenge is consumed so it cannot
|
||||
// be retried.
|
||||
if (user.disabled) {
|
||||
await prisma.verificationToken.deleteMany({
|
||||
where: {
|
||||
id: challenge.id,
|
||||
},
|
||||
});
|
||||
|
||||
clearTwoFactorChallengeCookie(c);
|
||||
|
||||
throw new AppError(AuthenticationErrorCode.AccountDisabled, {
|
||||
message: 'Account disabled',
|
||||
statusCode: 403,
|
||||
});
|
||||
}
|
||||
|
||||
const validationResult = await validateTwoFactorAuthentication({
|
||||
totpCode,
|
||||
backupCode,
|
||||
user,
|
||||
});
|
||||
|
||||
if (!validationResult.isValid) {
|
||||
// Failed codes do not consume the token but atomically increment its
|
||||
// attempt counter (audit-logged). The rate limiter fails open on DB
|
||||
// errors, so the counter is the hard bound — exhaustion consumes the
|
||||
// challenge.
|
||||
const { isExhausted } = await recordTwoFactorChallengeFailure(c, {
|
||||
challenge,
|
||||
requestMetadata,
|
||||
});
|
||||
|
||||
if (isExhausted) {
|
||||
throw new AppError(AuthenticationErrorCode.TwoFactorChallengeExpired, {
|
||||
message: 'Too many failed attempts. Restart the sign-in flow.',
|
||||
statusCode: 401,
|
||||
});
|
||||
}
|
||||
|
||||
throw new AppError(AuthenticationErrorCode.InvalidTwoFactorCode, {
|
||||
message: 'Invalid two factor code',
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const isBackupCodeRecovery = validationResult.method === 'backup';
|
||||
|
||||
// Token consumption, the deferred link action (if any) and the
|
||||
// backup-code recovery reset all commit atomically — a concurrent replay
|
||||
// of the same challenge fails the consumption count check cleanly.
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await consumeTwoFactorChallenge(tx, challenge.id);
|
||||
|
||||
if (challenge.metadata.action) {
|
||||
await executeTwoFactorChallengeAction(tx, {
|
||||
action: challenge.metadata.action,
|
||||
userId: challenge.userId,
|
||||
requestMetadata,
|
||||
});
|
||||
}
|
||||
|
||||
// Backup codes are recovery, not sign-in: a successful backup-code
|
||||
// challenge atomically resets 2FA so the user re-enrols.
|
||||
if (isBackupCodeRecovery) {
|
||||
await resetTwoFactorAfterBackupCodeUse({
|
||||
user,
|
||||
requestMetadata,
|
||||
tx,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// The session is created with the primary auth method stored at challenge
|
||||
// creation, and counts as second-factor verified.
|
||||
await onAuthorize(
|
||||
{
|
||||
userId: challenge.userId,
|
||||
authMethod: challenge.metadata.authMethod,
|
||||
twoFactorVerified: true,
|
||||
},
|
||||
c,
|
||||
);
|
||||
|
||||
clearTwoFactorChallengeCookie(c);
|
||||
|
||||
// A backup-code recovery lands on the re-enrolment page, carrying the
|
||||
// original destination as its returnTo.
|
||||
const redirectPath = isBackupCodeRecovery
|
||||
? `/onboarding/2fa?returnTo=${encodeURIComponent(challenge.metadata.redirectPath)}`
|
||||
: challenge.metadata.redirectPath;
|
||||
|
||||
return c.json(
|
||||
{
|
||||
redirectPath,
|
||||
},
|
||||
201,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -18,3 +18,17 @@ export const ZViewTwoFactorRecoveryCodesRequestSchema = z.object({
|
||||
});
|
||||
|
||||
export type TViewTwoFactorRecoveryCodesRequestSchema = z.infer<typeof ZViewTwoFactorRecoveryCodesRequestSchema>;
|
||||
|
||||
/**
|
||||
* Mirrors the email/password sign-in shape (`ZSignInSchema`) for the second
|
||||
* factor: one of `totpCode` or `backupCode`, plus the CSRF token the client
|
||||
* fetched before submitting (the challenge page is reached via a 302, not the
|
||||
* sign-in form, so it fetches its own).
|
||||
*/
|
||||
export const ZVerifyTwoFactorChallengeRequestSchema = z.object({
|
||||
totpCode: z.string().trim().optional(),
|
||||
backupCode: z.string().trim().optional(),
|
||||
csrfToken: z.string().trim(),
|
||||
});
|
||||
|
||||
export type TVerifyTwoFactorChallengeRequestSchema = z.infer<typeof ZVerifyTwoFactorChallengeRequestSchema>;
|
||||
|
||||
@@ -3,6 +3,12 @@ import { z } from 'zod';
|
||||
export const ZPasskeyAuthorizeSchema = z.object({
|
||||
csrfToken: z.string().min(1),
|
||||
credential: z.string().min(1),
|
||||
|
||||
/**
|
||||
* Optional client redirect path, validated server-side with
|
||||
* `isValidReturnTo`/`normalizeReturnTo` before being echoed back.
|
||||
*/
|
||||
redirectPath: z.string().optional(),
|
||||
});
|
||||
|
||||
export type TPasskeyAuthorizeSchema = z.infer<typeof ZPasskeyAuthorizeSchema>;
|
||||
|
||||
@@ -5,11 +5,12 @@ import {
|
||||
ORGANISATION_USER_ACCOUNT_TYPE,
|
||||
} from '@documenso/lib/constants/organisations';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { addUserToOrganisation } from '@documenso/lib/server-only/organisation/accept-organisation-invitation';
|
||||
import { jobs } from '@documenso/lib/jobs/client';
|
||||
import { ZOrganisationAccountLinkMetadataSchema } from '@documenso/lib/types/organisation';
|
||||
import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
||||
import { generateDatabaseId } from '@documenso/lib/universal/id';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { UserSecurityAuditLogType } from '@prisma/client';
|
||||
import { OrganisationGroupType, UserSecurityAuditLogType } from '@prisma/client';
|
||||
|
||||
export interface LinkOrganisationAccountOptions {
|
||||
token: string;
|
||||
@@ -23,8 +24,10 @@ export const linkOrganisationAccount = async ({ token, requestMeta }: LinkOrgani
|
||||
});
|
||||
}
|
||||
|
||||
// Delete the token since it contains unnecessary sensitive data.
|
||||
const verificationToken = await prisma.verificationToken.delete({
|
||||
// Read WITHOUT consuming: the token must stay retryable until membership
|
||||
// creation succeeds. Consumption happens atomically with the membership
|
||||
// creation below.
|
||||
const verificationToken = await prisma.verificationToken.findFirst({
|
||||
where: {
|
||||
token,
|
||||
identifier: ORGANISATION_ACCOUNT_LINK_VERIFICATION_TOKEN_IDENTIFIER,
|
||||
@@ -33,13 +36,9 @@ export const linkOrganisationAccount = async ({ token, requestMeta }: LinkOrgani
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
emailVerified: true,
|
||||
accounts: {
|
||||
select: {
|
||||
provider: true,
|
||||
providerAccountId: true,
|
||||
},
|
||||
},
|
||||
disabled: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -73,11 +72,47 @@ export const linkOrganisationAccount = async ({ token, requestMeta }: LinkOrgani
|
||||
|
||||
const user = verificationToken.user;
|
||||
|
||||
// Never link into (or activate membership for) a disabled account.
|
||||
if (user.disabled) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'Account is disabled',
|
||||
});
|
||||
}
|
||||
|
||||
// The confirmation is only valid for the email address it was sent to. An
|
||||
// email change between token issuance and confirmation invalidates it.
|
||||
if (user.email.toLowerCase() !== tokenMetadata.data.email.toLowerCase()) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: 'Verification token not found, used or expired',
|
||||
});
|
||||
}
|
||||
|
||||
const { clientOptions, organisation } = await getOrganisationAuthenticationPortalOptions({
|
||||
type: 'id',
|
||||
organisationId: tokenMetadata.data.organisationId,
|
||||
});
|
||||
|
||||
const { providerAccountId } = tokenMetadata.data.oauthConfig;
|
||||
|
||||
// Ownership conflict check: the provider subject must not already belong to
|
||||
// a different user. `createMany skipDuplicates` below would silently skip
|
||||
// in that case, which would confirm a link that never happened.
|
||||
const existingProviderAccount = await prisma.account.findFirst({
|
||||
where: {
|
||||
provider: clientOptions.id,
|
||||
providerAccountId,
|
||||
},
|
||||
select: {
|
||||
userId: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (existingProviderAccount && existingProviderAccount.userId !== user.id) {
|
||||
throw new AppError(AppErrorCode.ALREADY_EXISTS, {
|
||||
message: 'This identity provider account is already linked to another user',
|
||||
});
|
||||
}
|
||||
|
||||
const organisationMember = await prisma.organisationMember.findFirst({
|
||||
where: {
|
||||
userId: user.id,
|
||||
@@ -85,32 +120,34 @@ export const linkOrganisationAccount = async ({ token, requestMeta }: LinkOrgani
|
||||
},
|
||||
});
|
||||
|
||||
const oauthConfig = tokenMetadata.data.oauthConfig;
|
||||
const isProviderAccountLinked = existingProviderAccount !== null;
|
||||
|
||||
const userAlreadyLinked = user.accounts.find(
|
||||
(account) => account.provider === clientOptions.id && account.providerAccountId === oauthConfig.providerAccountId,
|
||||
);
|
||||
|
||||
if (organisationMember && userAlreadyLinked) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Link the user if not linked yet.
|
||||
if (!userAlreadyLinked) {
|
||||
// Link the provider account idempotently. `createMany` + `skipDuplicates`
|
||||
// (unique on provider + providerAccountId) can never clobber an existing
|
||||
// row and is safe under concurrent confirmation attempts. The account row
|
||||
// is intentionally created WITHOUT access/ID tokens — they are never stored
|
||||
// in the token metadata, and the portal re-authenticates against the IdP on
|
||||
// every sign-in.
|
||||
if (!isProviderAccountLinked) {
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.account.create({
|
||||
data: {
|
||||
type: ORGANISATION_USER_ACCOUNT_TYPE,
|
||||
provider: clientOptions.id,
|
||||
providerAccountId: oauthConfig.providerAccountId,
|
||||
access_token: oauthConfig.accessToken,
|
||||
expires_at: oauthConfig.expiresAt,
|
||||
token_type: 'Bearer',
|
||||
id_token: oauthConfig.idToken,
|
||||
userId: user.id,
|
||||
},
|
||||
const createdAccounts = await tx.account.createMany({
|
||||
data: [
|
||||
{
|
||||
type: ORGANISATION_USER_ACCOUNT_TYPE,
|
||||
provider: clientOptions.id,
|
||||
providerAccountId,
|
||||
userId: user.id,
|
||||
},
|
||||
],
|
||||
skipDuplicates: true,
|
||||
});
|
||||
|
||||
// A concurrent confirmation created the row first — nothing to do, and
|
||||
// the audit/verification side effects below belong to that request.
|
||||
if (createdAccounts.count === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Log link event.
|
||||
await tx.userSecurityAuditLog.create({
|
||||
data: {
|
||||
@@ -139,15 +176,66 @@ export const linkOrganisationAccount = async ({ token, requestMeta }: LinkOrgani
|
||||
});
|
||||
}
|
||||
|
||||
// Only add the user to the organisation if they are not already a member.
|
||||
// Done outside the above transaction to avoid nested transactions and
|
||||
// holding connections during the job trigger network I/O.
|
||||
if (!organisationMember) {
|
||||
await addUserToOrganisation({
|
||||
userId: user.id,
|
||||
organisationId: tokenMetadata.data.organisationId,
|
||||
organisationGroups: organisation.groups,
|
||||
organisationMemberRole: organisation.organisationAuthenticationPortal.defaultOrganisationRole,
|
||||
// Create the membership and consume the verification token ATOMICALLY. The
|
||||
// `deleteMany` count check means a concurrent confirmation loses cleanly
|
||||
// (no double membership, no P2025 500), while any failure before commit
|
||||
// leaves the token intact and retryable.
|
||||
await prisma.$transaction(async (tx) => {
|
||||
const deletedTokens = await tx.verificationToken.deleteMany({
|
||||
where: {
|
||||
id: verificationToken.id,
|
||||
},
|
||||
});
|
||||
|
||||
if (deletedTokens.count !== 1) {
|
||||
throw new AppError('ALREADY_USED');
|
||||
}
|
||||
|
||||
if (organisationMember) {
|
||||
return;
|
||||
}
|
||||
|
||||
const organisationGroupToUse = organisation.groups.find(
|
||||
(group) =>
|
||||
group.type === OrganisationGroupType.INTERNAL_ORGANISATION &&
|
||||
group.organisationRole === organisation.organisationAuthenticationPortal.defaultOrganisationRole,
|
||||
);
|
||||
|
||||
if (!organisationGroupToUse) {
|
||||
throw new AppError(AppErrorCode.UNKNOWN_ERROR, {
|
||||
message: 'Organisation group not found',
|
||||
});
|
||||
}
|
||||
|
||||
await tx.organisationMember.create({
|
||||
data: {
|
||||
id: generateDatabaseId('member'),
|
||||
userId: user.id,
|
||||
organisationId: tokenMetadata.data.organisationId,
|
||||
organisationGroupMembers: {
|
||||
create: {
|
||||
id: generateDatabaseId('group_member'),
|
||||
groupId: organisationGroupToUse.id,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// Best-effort notification AFTER the confirmation is committed — a failing
|
||||
// email job must not fail (or roll back) the confirmation itself.
|
||||
if (!organisationMember) {
|
||||
try {
|
||||
await jobs.triggerJob({
|
||||
name: 'send.organisation-member-joined.email',
|
||||
payload: {
|
||||
organisationId: tokenMetadata.data.organisationId,
|
||||
memberUserId: user.id,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
// Todo: (RR7) Add logging.
|
||||
console.error('Failed to trigger organisation member joined email', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -14,7 +14,7 @@ import crypto from 'crypto';
|
||||
import { DateTime } from 'luxon';
|
||||
import { createElement } from 'react';
|
||||
|
||||
export type SendOrganisationAccountLinkConfirmationEmailProps = TOrganisationAccountLinkMetadata & {
|
||||
export type SendOrganisationAccountLinkConfirmationEmailProps = Omit<TOrganisationAccountLinkMetadata, 'email'> & {
|
||||
organisationName: string;
|
||||
};
|
||||
|
||||
@@ -69,6 +69,9 @@ export const sendOrganisationAccountLinkConfirmationEmail = async ({
|
||||
type,
|
||||
userId,
|
||||
organisationId,
|
||||
// The address this confirmation is being sent to — asserted unchanged
|
||||
// at confirmation time.
|
||||
email: user.email,
|
||||
oauthConfig,
|
||||
} satisfies TOrganisationAccountLinkMetadata,
|
||||
userId,
|
||||
|
||||
@@ -8,11 +8,19 @@ import { createContext, useCallback, useContext, useEffect, useState } from 'rea
|
||||
import { useLocation } from 'react-router';
|
||||
|
||||
import { SKIP_QUERY_BATCH_META } from '../../constants/trpc';
|
||||
import type { TTwoFactorEnforcementStatus } from '../../utils/two-factor';
|
||||
|
||||
export type AppSession = {
|
||||
session: Session;
|
||||
user: SessionUser;
|
||||
organisations: TGetOrganisationSessionResponse;
|
||||
|
||||
/**
|
||||
* Instance-wide 2FA enforcement status for the current user + session,
|
||||
* computed server-side. Layout components read this to derive banners and
|
||||
* the enforcement redirect client-side without extra queries.
|
||||
*/
|
||||
twoFactorEnforcement: TTwoFactorEnforcementStatus;
|
||||
};
|
||||
|
||||
interface SessionProviderProps {
|
||||
@@ -94,6 +102,7 @@ export const SessionProvider = ({ children, initialSession }: SessionProviderPro
|
||||
session: newSession.session,
|
||||
user: newSession.user,
|
||||
organisations,
|
||||
twoFactorEnforcement: newSession.twoFactorEnforcement,
|
||||
});
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -31,6 +31,12 @@ export const ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP = {
|
||||
MANAGE_BILLING: [OrganisationMemberRole.ADMIN],
|
||||
DELETE_ORGANISATION_TRANSFER_REQUEST: [OrganisationMemberRole.ADMIN],
|
||||
MANAGE_ORGANISATION: [OrganisationMemberRole.ADMIN, OrganisationMemberRole.MANAGER],
|
||||
/**
|
||||
* Security-sensitive organisation settings (2FA enforcement). ADMIN only —
|
||||
* managers can manage day-to-day settings but must not be able to lock
|
||||
* members out (or unlock them) via enforcement policy.
|
||||
*/
|
||||
MANAGE_ORGANISATION_SECURITY: [OrganisationMemberRole.ADMIN],
|
||||
} satisfies Record<string, OrganisationMemberRole[]>;
|
||||
|
||||
/**
|
||||
|
||||
@@ -113,6 +113,9 @@ export const genericErrorCodeToTrpcErrorCodeMap: Record<string, { code: string;
|
||||
[AppErrorCode.SCHEMA_FAILED]: { code: 'INTERNAL_SERVER_ERROR', status: 500 },
|
||||
[AppErrorCode.TOO_MANY_REQUESTS]: { code: 'TOO_MANY_REQUESTS', status: 429 },
|
||||
[AppErrorCode.TWO_FACTOR_AUTH_FAILED]: { code: 'UNAUTHORIZED', status: 401 },
|
||||
// 403, not 401: the session is valid — the account is forbidden from the
|
||||
// resource until 2FA enforcement is satisfied.
|
||||
[AppErrorCode.TWO_FACTOR_REQUIRED]: { code: 'FORBIDDEN', status: 403 },
|
||||
[AppErrorCode.ENVELOPE_DRAFT]: { code: 'BAD_REQUEST', status: 400 },
|
||||
[AppErrorCode.ENVELOPE_COMPLETED]: { code: 'BAD_REQUEST', status: 400 },
|
||||
[AppErrorCode.ENVELOPE_REJECTED]: { code: 'BAD_REQUEST', status: 400 },
|
||||
@@ -341,7 +344,7 @@ export class AppError extends Error {
|
||||
() => 400 as const,
|
||||
)
|
||||
.with(AppErrorCode.UNAUTHORIZED, () => 401 as const)
|
||||
.with(AppErrorCode.FORBIDDEN, AppErrorCode.CSC_UNLICENSED, () => 403 as const)
|
||||
.with(AppErrorCode.FORBIDDEN, AppErrorCode.CSC_UNLICENSED, AppErrorCode.TWO_FACTOR_REQUIRED, () => 403 as const)
|
||||
.with(AppErrorCode.NOT_FOUND, () => 404 as const)
|
||||
.with(AppErrorCode.NOT_IMPLEMENTED, () => 501 as const)
|
||||
.otherwise(() => 500 as const);
|
||||
|
||||
@@ -4,6 +4,7 @@ import { UserSecurityAuditLogType } from '@prisma/client';
|
||||
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
import type { RequestMetadata } from '../../universal/extract-request-metadata';
|
||||
import { getInstanceTwoFactorEnforcementSetting } from './get-instance-two-factor-enforcement-setting';
|
||||
import { validateTwoFactorAuthentication } from './validate-2fa';
|
||||
|
||||
type DisableTwoFactorAuthenticationOptions = {
|
||||
@@ -19,22 +20,42 @@ export const disableTwoFactorAuthentication = async ({
|
||||
user,
|
||||
requestMetadata,
|
||||
}: DisableTwoFactorAuthenticationOptions) => {
|
||||
let isValid = false;
|
||||
|
||||
if (!totpCode && !backupCode) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST);
|
||||
}
|
||||
|
||||
if (totpCode) {
|
||||
isValid = await validateTwoFactorAuthentication({ totpCode, user });
|
||||
} else if (backupCode) {
|
||||
isValid = await validateTwoFactorAuthentication({ backupCode, user });
|
||||
// Disabling always breaks enforcement satisfaction (a passkey sign-in never
|
||||
// substitutes for enrolment), so while instance-wide enforcement is active
|
||||
// disabling is a straight path to being blocked — refuse it outright.
|
||||
const instanceEnforcementSetting = await getInstanceTwoFactorEnforcementSetting();
|
||||
|
||||
if (instanceEnforcementSetting !== null) {
|
||||
throw new AppError('TWO_FACTOR_DISABLE_FORBIDDEN', {
|
||||
message: 'Two-factor authentication cannot be disabled while it is required by this instance.',
|
||||
statusCode: 403,
|
||||
});
|
||||
}
|
||||
|
||||
const { isValid } = await validateTwoFactorAuthentication({ totpCode, backupCode, user });
|
||||
|
||||
if (!isValid) {
|
||||
throw new AppError(AppErrorCode.INCORRECT_TWO_FACTOR_CODE);
|
||||
}
|
||||
|
||||
// Org-only enforcement allows the disable (the rest of the app stays
|
||||
// usable) but the caller should warn that org/team context access blocks at
|
||||
// the org deadline — immediately if it is already past.
|
||||
const orgEnforcementCount = await prisma.organisationMember.count({
|
||||
where: {
|
||||
userId: user.id,
|
||||
organisation: {
|
||||
organisationGlobalSettings: {
|
||||
twoFactorRequired: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.user.update({
|
||||
where: {
|
||||
@@ -57,5 +78,5 @@ export const disableTwoFactorAuthentication = async ({
|
||||
});
|
||||
});
|
||||
|
||||
return true;
|
||||
return { orgEnforcementApplies: orgEnforcementCount > 0 };
|
||||
};
|
||||
|
||||
@@ -9,12 +9,22 @@ import { verifyTwoFactorAuthenticationToken } from './verify-2fa-token';
|
||||
type EnableTwoFactorAuthenticationOptions = {
|
||||
user: Pick<User, 'id' | 'email' | 'twoFactorEnabled' | 'twoFactorSecret'>;
|
||||
code: string;
|
||||
|
||||
/**
|
||||
* The session the enable request arrived on. A valid enable code proves
|
||||
* possession of the second factor, so this session is marked
|
||||
* `twoFactorVerified`. Other sessions of the same user intentionally stay
|
||||
* unverified — they must re-login to verify.
|
||||
*/
|
||||
sessionId: string;
|
||||
|
||||
requestMetadata?: RequestMetadata;
|
||||
};
|
||||
|
||||
export const enableTwoFactorAuthentication = async ({
|
||||
user,
|
||||
code,
|
||||
sessionId,
|
||||
requestMetadata,
|
||||
}: EnableTwoFactorAuthenticationOptions) => {
|
||||
if (user.twoFactorEnabled) {
|
||||
@@ -34,21 +44,58 @@ export const enableTwoFactorAuthentication = async ({
|
||||
let recoveryCodes: string[] = [];
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
const updatedUser = await tx.user.update({
|
||||
// Conditional update: the write itself asserts 2FA is still disabled AND
|
||||
// the stored secret is the one the code was just verified against, so a
|
||||
// concurrent enable or setup-time secret rotation loses the race cleanly
|
||||
// instead of enabling 2FA with an unverified secret.
|
||||
const { count } = await tx.user.updateMany({
|
||||
where: {
|
||||
id: user.id,
|
||||
twoFactorEnabled: false,
|
||||
twoFactorSecret: user.twoFactorSecret,
|
||||
},
|
||||
data: {
|
||||
twoFactorEnabled: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (count === 0) {
|
||||
throw new AppError('TWO_FACTOR_ALREADY_ENABLED', {
|
||||
message:
|
||||
'Two-factor authentication is already enabled, or the setup was restarted after this code was issued. Restart setup and try again.',
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const updatedUser = await tx.user.findFirst({
|
||||
where: {
|
||||
id: user.id,
|
||||
},
|
||||
});
|
||||
|
||||
if (!updatedUser) {
|
||||
throw new AppError('MISSING_BACKUP_CODE');
|
||||
}
|
||||
|
||||
recoveryCodes = getBackupCodes({ user: updatedUser }) ?? [];
|
||||
|
||||
if (recoveryCodes.length === 0) {
|
||||
throw new AppError('MISSING_BACKUP_CODE');
|
||||
}
|
||||
|
||||
// `updateMany` so a session that expired mid-request is a noop rather
|
||||
// than a thrown P2025. The user filter guards against marking a session
|
||||
// that does not belong to this user.
|
||||
await tx.session.updateMany({
|
||||
where: {
|
||||
id: sessionId,
|
||||
userId: user.id,
|
||||
},
|
||||
data: {
|
||||
twoFactorVerified: true,
|
||||
},
|
||||
});
|
||||
|
||||
await tx.userSecurityAuditLog.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import type { InstanceTwoFactorEnforcementStoredConfig } from '../../utils/two-factor';
|
||||
import { isInstanceTwoFactorEnforcementActive } from '../../utils/two-factor';
|
||||
import { assertLicensedFor } from '../license/assert-licensed-for';
|
||||
import {
|
||||
SITE_SETTINGS_TWO_FACTOR_ENFORCEMENT_ID,
|
||||
ZSiteSettingsTwoFactorEnforcementSchema,
|
||||
} from '../site-settings/schemas/two-factor-enforcement';
|
||||
|
||||
export type TInstanceTwoFactorEnforcementConfig = InstanceTwoFactorEnforcementStoredConfig & {
|
||||
isLicensed: boolean;
|
||||
|
||||
/**
|
||||
* Whether enforcement is currently active (enabled AND licensed) — the same
|
||||
* activation decision the policy getter applies.
|
||||
*/
|
||||
isActive: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the raw stored instance 2FA enforcement configuration (schema
|
||||
* defaults when the row is absent or malformed) together with the license and
|
||||
* activation state.
|
||||
*
|
||||
* FOR ADMIN SETTINGS ONLY — never use this from enforcement code. Enforcement
|
||||
* reads `getInstanceTwoFactorEnforcementSetting()`, which returns `null`
|
||||
* unless the policy is actually active. This getter deliberately exposes the
|
||||
* stored values that the policy getter hides so the admin UI can render the
|
||||
* "configured but inactive" (e.g. license lapsed) state with a disable-only
|
||||
* affordance.
|
||||
*/
|
||||
export const getInstanceTwoFactorEnforcementConfig = async (): Promise<TInstanceTwoFactorEnforcementConfig> => {
|
||||
const settingRow = await prisma.siteSettings.findFirst({
|
||||
where: {
|
||||
id: SITE_SETTINGS_TWO_FACTOR_ENFORCEMENT_ID,
|
||||
},
|
||||
});
|
||||
|
||||
// A missing or malformed row is presented as the unconfigured defaults,
|
||||
// mirroring how the policy getter treats it as unconfigured.
|
||||
const parsedSetting = settingRow ? ZSiteSettingsTwoFactorEnforcementSchema.safeParse(settingRow) : null;
|
||||
|
||||
const storedConfig: InstanceTwoFactorEnforcementStoredConfig = parsedSetting?.success
|
||||
? {
|
||||
enabled: parsedSetting.data.enabled,
|
||||
gracePeriodDays: parsedSetting.data.data.gracePeriodDays,
|
||||
enforcedFrom: parsedSetting.data.data.enforcedFrom,
|
||||
}
|
||||
: {
|
||||
enabled: false,
|
||||
gracePeriodDays: 7,
|
||||
enforcedFrom: null,
|
||||
};
|
||||
|
||||
const isLicensed = await assertLicensedFor('instanceTwoFactorEnforcement')
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
|
||||
return {
|
||||
...storedConfig,
|
||||
isLicensed,
|
||||
isActive: isInstanceTwoFactorEnforcementActive({
|
||||
setting: { enabled: storedConfig.enabled },
|
||||
isLicensed,
|
||||
}),
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,52 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { isInstanceTwoFactorEnforcementActive } from '../../utils/two-factor';
|
||||
import { assertLicensedFor } from '../license/assert-licensed-for';
|
||||
import type { TSiteSettingsTwoFactorEnforcementSchema } from '../site-settings/schemas/two-factor-enforcement';
|
||||
import {
|
||||
SITE_SETTINGS_TWO_FACTOR_ENFORCEMENT_ID,
|
||||
ZSiteSettingsTwoFactorEnforcementSchema,
|
||||
} from '../site-settings/schemas/two-factor-enforcement';
|
||||
|
||||
/**
|
||||
* Returns the instance-wide 2FA enforcement setting, or `null` when
|
||||
* enforcement is not active.
|
||||
*
|
||||
* Enforcement is only active when the `site.two-factor-enforcement` row
|
||||
* exists, parses, is enabled, AND the license grants
|
||||
* `instanceTwoFactorEnforcement` — a manually inserted row on an unlicensed
|
||||
* instance is a silent noop.
|
||||
*
|
||||
* Enforcement code must read only this getter. Admin settings UI which needs
|
||||
* the raw "configured but inactive" values uses its own config getter.
|
||||
*/
|
||||
export const getInstanceTwoFactorEnforcementSetting =
|
||||
async (): Promise<TSiteSettingsTwoFactorEnforcementSchema | null> => {
|
||||
const settingRow = await prisma.siteSettings.findFirst({
|
||||
where: {
|
||||
id: SITE_SETTINGS_TWO_FACTOR_ENFORCEMENT_ID,
|
||||
},
|
||||
});
|
||||
|
||||
if (!settingRow) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// A malformed row is treated as unconfigured rather than throwing, so a
|
||||
// bad manual insert cannot break every enforcement check.
|
||||
const parsedSetting = ZSiteSettingsTwoFactorEnforcementSchema.safeParse(settingRow);
|
||||
|
||||
if (!parsedSetting.success) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isLicensed = await assertLicensedFor('instanceTwoFactorEnforcement')
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
|
||||
if (!isInstanceTwoFactorEnforcementActive({ setting: parsedSetting.data, isLicensed })) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return parsedSetting.data;
|
||||
};
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { Session, User } from '@prisma/client';
|
||||
|
||||
import type { TTwoFactorEnforcementStatus } from '../../utils/two-factor';
|
||||
import { computeTwoFactorEnforcementStatus } from '../../utils/two-factor';
|
||||
import { getInstanceTwoFactorEnforcementSetting } from './get-instance-two-factor-enforcement-setting';
|
||||
|
||||
export type GetTwoFactorEnforcementStatusOptions = {
|
||||
/**
|
||||
* The user to evaluate. Callers that already hold the user row pass the
|
||||
* fields in directly — no redundant query is made here.
|
||||
*/
|
||||
user: Pick<User, 'twoFactorEnabled' | 'twoFactorGraceStartedAt'>;
|
||||
|
||||
/**
|
||||
* The current session, or null for contexts that carry no session (e.g.
|
||||
* API access), which never count as verified.
|
||||
*/
|
||||
session: Pick<Session, 'twoFactorVerified'> | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Instance-wide 2FA enforcement status for a user + session.
|
||||
*
|
||||
* Thin I/O wrapper around the pure `computeTwoFactorEnforcementStatus`:
|
||||
* loads the instance setting (null unless configured + licensed) and derives
|
||||
* the deadline from `max(user.twoFactorGraceStartedAt, setting.enforcedFrom)
|
||||
* + setting.gracePeriodDays`.
|
||||
*
|
||||
* `isBlocked` is computed here once — consumers branch only on it; deadline
|
||||
* fields are for banners.
|
||||
*/
|
||||
export const getTwoFactorEnforcementStatus = async (
|
||||
options: GetTwoFactorEnforcementStatusOptions,
|
||||
): Promise<TTwoFactorEnforcementStatus> => {
|
||||
const { user, session } = options;
|
||||
|
||||
const setting = await getInstanceTwoFactorEnforcementSetting();
|
||||
|
||||
return computeTwoFactorEnforcementStatus({
|
||||
graceWindow: setting
|
||||
? {
|
||||
anchors: [
|
||||
user.twoFactorGraceStartedAt,
|
||||
setting.data.enforcedFrom ? new Date(setting.data.enforcedFrom) : null,
|
||||
],
|
||||
gracePeriodDays: setting.data.gracePeriodDays,
|
||||
}
|
||||
: null,
|
||||
userTwoFactorEnabled: user.twoFactorEnabled,
|
||||
sessionTwoFactorVerified: session?.twoFactorVerified ?? false,
|
||||
now: new Date(),
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,191 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import type { Session, User } from '@prisma/client';
|
||||
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
import type { OrganisationTwoFactorEnforcementSettings } from '../../utils/two-factor';
|
||||
import { computeOrganisationTwoFactorEnforcementStatus, isTwoFactorSatisfied } from '../../utils/two-factor';
|
||||
import { getTwoFactorEnforcementStatus } from './get-two-factor-enforcement-status';
|
||||
|
||||
export type EnforcementUser = Pick<User, 'id' | 'twoFactorEnabled' | 'twoFactorGraceStartedAt'>;
|
||||
|
||||
export type EnforcementSession = Pick<Session, 'twoFactorVerified'> | null;
|
||||
|
||||
/**
|
||||
* The minimal data required to evaluate a user's 2FA enforcement status for a
|
||||
* single organisation.
|
||||
*/
|
||||
export type OrganisationTwoFactorScope = {
|
||||
organisationId: string;
|
||||
memberCreatedAt: Date;
|
||||
settings: OrganisationTwoFactorEnforcementSettings;
|
||||
};
|
||||
|
||||
export type LoadOrganisationTwoFactorScopesOptions = {
|
||||
userId: number;
|
||||
|
||||
/**
|
||||
* The organisations to load. `undefined` loads every organisation the user
|
||||
* is a member of (used for cross-organisation queries where the data scope
|
||||
* is "all my organisations").
|
||||
*/
|
||||
organisationIds?: string[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Membership-qualified lookup of the per-organisation 2FA enforcement inputs.
|
||||
*
|
||||
* Organisations the user is not a member of are silently dropped — the
|
||||
* underlying handler's own authorization will reject those anyway, and
|
||||
* enforcement only applies to organisations the user can actually access.
|
||||
*/
|
||||
export const loadOrganisationTwoFactorScopes = async (
|
||||
options: LoadOrganisationTwoFactorScopesOptions,
|
||||
): Promise<OrganisationTwoFactorScope[]> => {
|
||||
const { userId, organisationIds } = options;
|
||||
|
||||
if (organisationIds && organisationIds.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const organisations = await prisma.organisation.findMany({
|
||||
where: {
|
||||
...(organisationIds ? { id: { in: organisationIds } } : {}),
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
organisationGlobalSettings: {
|
||||
select: {
|
||||
twoFactorRequired: true,
|
||||
twoFactorGracePeriodDays: true,
|
||||
twoFactorEnforcedFrom: true,
|
||||
},
|
||||
},
|
||||
members: {
|
||||
where: {
|
||||
userId,
|
||||
},
|
||||
take: 1,
|
||||
select: {
|
||||
createdAt: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return organisations.flatMap((organisation) => {
|
||||
const [member] = organisation.members;
|
||||
|
||||
// Defensive: the membership filter above guarantees a member row exists.
|
||||
if (!member) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
organisationId: organisation.id,
|
||||
memberCreatedAt: member.createdAt,
|
||||
settings: organisation.organisationGlobalSettings,
|
||||
},
|
||||
];
|
||||
});
|
||||
};
|
||||
|
||||
const throwTwoFactorRequiredError = (): never => {
|
||||
throw new AppError(AppErrorCode.TWO_FACTOR_REQUIRED, {
|
||||
message: 'Two-factor authentication is required to access this resource.',
|
||||
userMessage: 'Two-factor authentication is required. Please enable it to continue.',
|
||||
statusCode: 403,
|
||||
});
|
||||
};
|
||||
|
||||
export type AssertOrganisationScopesTwoFactorEnforcementOptions = {
|
||||
user: EnforcementUser;
|
||||
session: EnforcementSession;
|
||||
scopes: OrganisationTwoFactorScope[];
|
||||
now?: Date;
|
||||
};
|
||||
|
||||
/**
|
||||
* Throws `AppError(TWO_FACTOR_REQUIRED)` when the user is blocked by ANY of
|
||||
* the provided organisation scopes.
|
||||
*/
|
||||
export const assertOrganisationScopesTwoFactorEnforcement = (
|
||||
options: AssertOrganisationScopesTwoFactorEnforcementOptions,
|
||||
): void => {
|
||||
const { user, session, scopes, now = new Date() } = options;
|
||||
|
||||
for (const scope of scopes) {
|
||||
const status = computeOrganisationTwoFactorEnforcementStatus({
|
||||
organisationSettings: scope.settings,
|
||||
memberCreatedAt: scope.memberCreatedAt,
|
||||
userTwoFactorEnabled: user.twoFactorEnabled,
|
||||
userTwoFactorGraceStartedAt: user.twoFactorGraceStartedAt,
|
||||
sessionTwoFactorVerified: session?.twoFactorVerified ?? false,
|
||||
now,
|
||||
});
|
||||
|
||||
if (status.required && status.isBlocked) {
|
||||
throwTwoFactorRequiredError();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export type AssertTwoFactorEnforcementForSessionOptions = {
|
||||
user: EnforcementUser;
|
||||
session: EnforcementSession;
|
||||
|
||||
/**
|
||||
* Organisations whose enforcement policy applies to this request.
|
||||
* `undefined` skips the organisation assert entirely (instance assert only).
|
||||
*/
|
||||
organisationIds?: string[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Shared 2FA enforcement assert for session-authenticated endpoints outside
|
||||
* of tRPC (e.g. the Hono file/AI routes).
|
||||
*
|
||||
* - Satisfied users (enrolled + verified session) can never be blocked at
|
||||
* either level, so they early-out without any queries.
|
||||
* - Instance enforcement blocks everything for the user.
|
||||
* - Organisation enforcement blocks when any of the provided organisations'
|
||||
* policies block the user.
|
||||
*/
|
||||
export const assertTwoFactorEnforcementForSession = async (
|
||||
options: AssertTwoFactorEnforcementForSessionOptions,
|
||||
): Promise<void> => {
|
||||
const { user, session, organisationIds } = options;
|
||||
|
||||
// Cheap early-out: a satisfied user cannot be blocked by instance or
|
||||
// organisation enforcement (isBlocked requires !isSatisfied).
|
||||
if (
|
||||
isTwoFactorSatisfied({
|
||||
userTwoFactorEnabled: user.twoFactorEnabled,
|
||||
sessionTwoFactorVerified: session?.twoFactorVerified ?? false,
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const instanceStatus = await getTwoFactorEnforcementStatus({ user, session });
|
||||
|
||||
if (instanceStatus.required && instanceStatus.isBlocked) {
|
||||
throwTwoFactorRequiredError();
|
||||
}
|
||||
|
||||
if (!organisationIds || organisationIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const scopes = await loadOrganisationTwoFactorScopes({
|
||||
userId: user.id,
|
||||
organisationIds,
|
||||
});
|
||||
|
||||
assertOrganisationScopesTwoFactorEnforcement({ user, session, scopes });
|
||||
};
|
||||
@@ -0,0 +1,82 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import type { Prisma, User } from '@prisma/client';
|
||||
import { UserSecurityAuditLogType } from '@prisma/client';
|
||||
|
||||
import { AppError } from '../../errors/app-error';
|
||||
import type { RequestMetadata } from '../../universal/extract-request-metadata';
|
||||
|
||||
type ResetTwoFactorAfterBackupCodeUseOptions = {
|
||||
/**
|
||||
* The user whose backup code was just validated. `twoFactorBackupCodes`
|
||||
* must be the exact stored value the code was validated against.
|
||||
*/
|
||||
user: Pick<User, 'id' | 'twoFactorBackupCodes'>;
|
||||
requestMetadata?: RequestMetadata;
|
||||
|
||||
/**
|
||||
* Optional transaction client so callers (e.g. the 2FA challenge endpoint)
|
||||
* can run the recovery reset atomically with their own writes, such as
|
||||
* challenge token consumption. When omitted a dedicated transaction is
|
||||
* used.
|
||||
*/
|
||||
tx?: Prisma.TransactionClient;
|
||||
};
|
||||
|
||||
/**
|
||||
* Backup codes are recovery, not sign-in: a successful backup-code sign-in
|
||||
* proves the authenticator is unavailable, so the user's 2FA configuration is
|
||||
* atomically reset and they are sent to re-enrol.
|
||||
*
|
||||
* The conditional update requires 2FA to still be enabled with the exact
|
||||
* backup-code state the code was validated against, so concurrent uses cannot
|
||||
* double-spend — the loser of the race fails the count check and the sign-in
|
||||
* is rejected.
|
||||
*
|
||||
* Audit trail: reuses `AUTH_2FA_DISABLE` — the effect on the account is
|
||||
* identical to a disable. (A dedicated enum value would require a migration;
|
||||
* the plan only mandates a new value for the admin reset in a later step.)
|
||||
*/
|
||||
export const resetTwoFactorAfterBackupCodeUse = async ({
|
||||
user,
|
||||
requestMetadata,
|
||||
tx,
|
||||
}: ResetTwoFactorAfterBackupCodeUseOptions) => {
|
||||
const run = async (client: Prisma.TransactionClient) => {
|
||||
const { count } = await client.user.updateMany({
|
||||
where: {
|
||||
id: user.id,
|
||||
twoFactorEnabled: true,
|
||||
twoFactorBackupCodes: user.twoFactorBackupCodes,
|
||||
},
|
||||
data: {
|
||||
twoFactorEnabled: false,
|
||||
twoFactorSecret: null,
|
||||
twoFactorBackupCodes: null,
|
||||
},
|
||||
});
|
||||
|
||||
if (count === 0) {
|
||||
throw new AppError('INCORRECT_TWO_FACTOR_CODE', {
|
||||
message: 'The backup code has already been consumed.',
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
await client.userSecurityAuditLog.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
type: UserSecurityAuditLogType.AUTH_2FA_DISABLE,
|
||||
userAgent: requestMetadata?.userAgent,
|
||||
ipAddress: requestMetadata?.ipAddress,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
if (tx) {
|
||||
await run(tx);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
await prisma.$transaction(run);
|
||||
};
|
||||
@@ -5,6 +5,7 @@ import crypto from 'crypto';
|
||||
import { createTOTPKeyURI } from 'oslo/otp';
|
||||
|
||||
import { DOCUMENSO_ENCRYPTION_KEY } from '../../constants/crypto';
|
||||
import { AppError } from '../../errors/app-error';
|
||||
import { symmetricEncrypt } from '../../universal/crypto';
|
||||
|
||||
type SetupTwoFactorAuthenticationOptions = {
|
||||
@@ -31,9 +32,15 @@ export const setupTwoFactorAuthentication = async ({ user }: SetupTwoFactorAuthe
|
||||
const uri = createTOTPKeyURI(ISSUER, accountName, secret);
|
||||
const encodedSecret = base32.encode(new Uint8Array(secret));
|
||||
|
||||
await prisma.user.update({
|
||||
// Conditional update rather than a read-then-write pre-check: the write
|
||||
// itself asserts 2FA is not enabled, so a concurrent enable can never be
|
||||
// silently clobbered by a secret/backup-code rotation. Users with 2FA
|
||||
// enabled must disable it (which requires a valid code) before re-running
|
||||
// setup.
|
||||
const { count } = await prisma.user.updateMany({
|
||||
where: {
|
||||
id: user.id,
|
||||
twoFactorEnabled: false,
|
||||
},
|
||||
data: {
|
||||
twoFactorEnabled: false,
|
||||
@@ -48,6 +55,13 @@ export const setupTwoFactorAuthentication = async ({ user }: SetupTwoFactorAuthe
|
||||
},
|
||||
});
|
||||
|
||||
if (count === 0) {
|
||||
throw new AppError('TWO_FACTOR_ALREADY_ENABLED', {
|
||||
message: 'Two-factor authentication is already enabled. Disable it before running setup again.',
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
secret: encodedSecret,
|
||||
uri,
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { base32 } from '@scure/base';
|
||||
import crypto from 'crypto';
|
||||
import { generateHOTP } from 'oslo/otp';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { AppError } from '../../errors/app-error';
|
||||
import { symmetricEncrypt } from '../../universal/crypto';
|
||||
|
||||
// `DOCUMENSO_ENCRYPTION_KEY` is captured at module load, so the env var must
|
||||
// be set before `validate-2fa` (via `verify-2fa-token`/`verify-backup-code`)
|
||||
// is imported. This is real environment configuration, not a mock — the code
|
||||
// under test performs no I/O.
|
||||
process.env.NEXT_PRIVATE_ENCRYPTION_KEY ??= 'test-encryption-key';
|
||||
|
||||
const ENCRYPTION_KEY = process.env.NEXT_PRIVATE_ENCRYPTION_KEY ?? 'test-encryption-key';
|
||||
|
||||
const { validateTwoFactorAuthentication } = await import('./validate-2fa');
|
||||
|
||||
const BACKUP_CODES = ['AAAAA-AAAAA', 'BBBBB-BBBBB'];
|
||||
|
||||
const createUser = (overrides: Partial<Parameters<typeof validateTwoFactorAuthentication>[0]['user']> = {}) => {
|
||||
const secret = crypto.randomBytes(10);
|
||||
const encodedSecret = base32.encode(new Uint8Array(secret));
|
||||
|
||||
return {
|
||||
user: {
|
||||
id: 1,
|
||||
email: 'user@example.com',
|
||||
twoFactorEnabled: true,
|
||||
twoFactorSecret: symmetricEncrypt({ data: encodedSecret, key: ENCRYPTION_KEY }),
|
||||
twoFactorBackupCodes: symmetricEncrypt({ data: JSON.stringify(BACKUP_CODES), key: ENCRYPTION_KEY }),
|
||||
...overrides,
|
||||
},
|
||||
secret,
|
||||
};
|
||||
};
|
||||
|
||||
const generateCurrentTotpCode = async (secret: Buffer) => {
|
||||
const counter = Math.floor(Date.now() / 30_000);
|
||||
|
||||
return await generateHOTP(new Uint8Array(secret), counter);
|
||||
};
|
||||
|
||||
describe('validateTwoFactorAuthentication', () => {
|
||||
it('returns method totp for a valid TOTP code', async () => {
|
||||
const { user, secret } = createUser();
|
||||
|
||||
const totpCode = await generateCurrentTotpCode(secret);
|
||||
|
||||
await expect(validateTwoFactorAuthentication({ totpCode, user })).resolves.toEqual({
|
||||
isValid: true,
|
||||
method: 'totp',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns invalid for an incorrect TOTP code', async () => {
|
||||
const { user, secret } = createUser();
|
||||
|
||||
const validCode = await generateCurrentTotpCode(secret);
|
||||
const invalidCode = validCode === '000000' ? '000001' : '000000';
|
||||
|
||||
await expect(validateTwoFactorAuthentication({ totpCode: invalidCode, user })).resolves.toEqual({
|
||||
isValid: false,
|
||||
method: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns method backup for a valid backup code', async () => {
|
||||
const { user } = createUser();
|
||||
|
||||
await expect(validateTwoFactorAuthentication({ backupCode: BACKUP_CODES[0], user })).resolves.toEqual({
|
||||
isValid: true,
|
||||
method: 'backup',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns invalid for an unknown backup code', async () => {
|
||||
const { user } = createUser();
|
||||
|
||||
await expect(validateTwoFactorAuthentication({ backupCode: 'ZZZZZ-ZZZZZ', user })).resolves.toEqual({
|
||||
isValid: false,
|
||||
method: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('prefers the TOTP code when both credentials are provided', async () => {
|
||||
const { user, secret } = createUser();
|
||||
|
||||
const totpCode = await generateCurrentTotpCode(secret);
|
||||
|
||||
await expect(validateTwoFactorAuthentication({ totpCode, backupCode: BACKUP_CODES[0], user })).resolves.toEqual({
|
||||
isValid: true,
|
||||
method: 'totp',
|
||||
});
|
||||
});
|
||||
|
||||
it('throws when 2FA is not enabled', async () => {
|
||||
const { user } = createUser({ twoFactorEnabled: false });
|
||||
|
||||
await expect(validateTwoFactorAuthentication({ totpCode: '000000', user })).rejects.toThrowError(AppError);
|
||||
});
|
||||
|
||||
it('throws when no credentials are provided', async () => {
|
||||
const { user } = createUser();
|
||||
|
||||
await expect(validateTwoFactorAuthentication({ user })).rejects.toThrowError(AppError);
|
||||
});
|
||||
});
|
||||
@@ -10,11 +10,21 @@ type ValidateTwoFactorAuthenticationOptions = {
|
||||
user: Pick<User, 'id' | 'email' | 'twoFactorEnabled' | 'twoFactorSecret' | 'twoFactorBackupCodes'>;
|
||||
};
|
||||
|
||||
export type TTwoFactorAuthenticationMethod = 'totp' | 'backup';
|
||||
|
||||
/**
|
||||
* Discriminates which second factor matched so callers can treat backup codes
|
||||
* as recovery (which resets 2FA) rather than a regular sign-in factor.
|
||||
*/
|
||||
export type TValidateTwoFactorAuthenticationResult =
|
||||
| { isValid: true; method: TTwoFactorAuthenticationMethod }
|
||||
| { isValid: false; method: null };
|
||||
|
||||
export const validateTwoFactorAuthentication = async ({
|
||||
backupCode,
|
||||
totpCode,
|
||||
user,
|
||||
}: ValidateTwoFactorAuthenticationOptions) => {
|
||||
}: ValidateTwoFactorAuthenticationOptions): Promise<TValidateTwoFactorAuthenticationResult> => {
|
||||
if (!user.twoFactorEnabled) {
|
||||
throw new AppError(AppErrorCode.TWO_FACTOR_SETUP_REQUIRED);
|
||||
}
|
||||
@@ -24,11 +34,15 @@ export const validateTwoFactorAuthentication = async ({
|
||||
}
|
||||
|
||||
if (totpCode) {
|
||||
return await verifyTwoFactorAuthenticationToken({ user, totpCode });
|
||||
const isValid = await verifyTwoFactorAuthenticationToken({ user, totpCode });
|
||||
|
||||
return isValid ? { isValid: true, method: 'totp' } : { isValid: false, method: null };
|
||||
}
|
||||
|
||||
if (backupCode) {
|
||||
return verifyBackupCode({ user, backupCode });
|
||||
const isValid = verifyBackupCode({ user, backupCode });
|
||||
|
||||
return isValid ? { isValid: true, method: 'backup' } : { isValid: false, method: null };
|
||||
}
|
||||
|
||||
throw new AppError(AppErrorCode.TWO_FACTOR_MISSING_CREDENTIALS);
|
||||
|
||||
@@ -10,10 +10,10 @@ type ViewBackupCodesOptions = {
|
||||
};
|
||||
|
||||
export const viewBackupCodes = async ({ token, user }: ViewBackupCodesOptions) => {
|
||||
let isValid = await validateTwoFactorAuthentication({ totpCode: token, user });
|
||||
let { isValid } = await validateTwoFactorAuthentication({ totpCode: token, user });
|
||||
|
||||
if (!isValid) {
|
||||
isValid = await validateTwoFactorAuthentication({ backupCode: token, user });
|
||||
({ isValid } = await validateTwoFactorAuthentication({ backupCode: token, user }));
|
||||
}
|
||||
|
||||
if (!isValid) {
|
||||
|
||||
@@ -13,7 +13,16 @@ export const createPasskeySigninOptions = async ({ sessionId }: CreatePasskeySig
|
||||
|
||||
const options = await generateAuthenticationOptions({
|
||||
rpID: rpId,
|
||||
userVerification: 'preferred',
|
||||
// A passkey sign-in counts as a trusted second factor (the session is
|
||||
// created `twoFactorVerified: true`), so user verification (PIN or
|
||||
// biometric) is mandatory — possession of the authenticator alone is a
|
||||
// single factor.
|
||||
//
|
||||
// Release note: authenticators that cannot perform user verification
|
||||
// (e.g. some older U2F-style security keys) can no longer be used to sign
|
||||
// in; affected users should sign in via another method and register a
|
||||
// UV-capable passkey.
|
||||
userVerification: 'required',
|
||||
timeout,
|
||||
});
|
||||
|
||||
|
||||
@@ -37,6 +37,10 @@ export const getApiTokenByToken = async ({ token, bypassRateLimit = false }: Get
|
||||
name: true,
|
||||
email: true,
|
||||
disabled: true,
|
||||
// Kept in sync with the `user` select below — team tokens
|
||||
// substitute the organisation owner as the acting user.
|
||||
twoFactorEnabled: true,
|
||||
twoFactorGraceStartedAt: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -49,6 +53,11 @@ export const getApiTokenByToken = async ({ token, bypassRateLimit = false }: Get
|
||||
name: true,
|
||||
email: true,
|
||||
disabled: true,
|
||||
// Selected so `ctx.user` keeps a consistent shape between the
|
||||
// session and API-token auth branches. API-token access itself is
|
||||
// exempt from 2FA enforcement (machine access).
|
||||
twoFactorEnabled: true,
|
||||
twoFactorGraceStartedAt: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -59,6 +59,28 @@ export const passkeyRateLimit = createRateLimit({
|
||||
window: '15m',
|
||||
});
|
||||
|
||||
/**
|
||||
* IP-scoped limit for the unauthenticated 2FA challenge endpoint, checked
|
||||
* BEFORE the challenge cookie is resolved so hammering without a valid cookie
|
||||
* is bounded without any DB token lookups.
|
||||
*/
|
||||
export const twoFactorChallengeIpRateLimit = createRateLimit({
|
||||
action: 'auth.2fa-challenge.ip',
|
||||
max: 30,
|
||||
window: '15m',
|
||||
});
|
||||
|
||||
/**
|
||||
* Per-user limit for the 2FA challenge endpoint, checked AFTER the challenge
|
||||
* cookie resolves to a user. No `globalMax` — the IP bound is enforced by
|
||||
* `twoFactorChallengeIpRateLimit` above.
|
||||
*/
|
||||
export const twoFactorChallengeUserRateLimit = createRateLimit({
|
||||
action: 'auth.2fa-challenge.user',
|
||||
max: 10,
|
||||
window: '15m',
|
||||
});
|
||||
|
||||
export const linkOrgAccountRateLimit = createRateLimit({
|
||||
action: 'auth.link-org-account',
|
||||
max: 5,
|
||||
|
||||
@@ -3,11 +3,13 @@ import { z } from 'zod';
|
||||
import { ZSiteSettingsBannerSchema } from './schemas/banner';
|
||||
import { ZSiteSettingsEmailBlocklistSchema } from './schemas/email-blocklist';
|
||||
import { ZSiteSettingsTelemetrySchema } from './schemas/telemetry';
|
||||
import { ZSiteSettingsTwoFactorEnforcementSchema } from './schemas/two-factor-enforcement';
|
||||
|
||||
export const ZSiteSettingSchema = z.union([
|
||||
ZSiteSettingsBannerSchema,
|
||||
ZSiteSettingsEmailBlocklistSchema,
|
||||
ZSiteSettingsTelemetrySchema,
|
||||
ZSiteSettingsTwoFactorEnforcementSchema,
|
||||
]);
|
||||
|
||||
export type TSiteSettingSchema = z.infer<typeof ZSiteSettingSchema>;
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { ZSiteSettingsBaseSchema } from './_base';
|
||||
|
||||
export const SITE_SETTINGS_TWO_FACTOR_ENFORCEMENT_ID = 'site.two-factor-enforcement';
|
||||
|
||||
export const ZSiteSettingsTwoFactorEnforcementSchema = ZSiteSettingsBaseSchema.extend({
|
||||
id: z.literal(SITE_SETTINGS_TWO_FACTOR_ENFORCEMENT_ID),
|
||||
data: z
|
||||
.object({
|
||||
gracePeriodDays: z.number().int().min(0).max(365),
|
||||
/**
|
||||
* Set server-side on each off→on transition of `enabled`, preserved
|
||||
* otherwise. ISO datetime string, or null when never enabled.
|
||||
*/
|
||||
enforcedFrom: z.string().datetime().nullable(),
|
||||
})
|
||||
.optional()
|
||||
.default({
|
||||
gracePeriodDays: 7,
|
||||
enforcedFrom: null,
|
||||
}),
|
||||
});
|
||||
|
||||
export type TSiteSettingsTwoFactorEnforcementSchema = z.infer<typeof ZSiteSettingsTwoFactorEnforcementSchema>;
|
||||
@@ -13,6 +13,7 @@ export const ZLicenseClaimSchema = z.object({
|
||||
billing: z.boolean().optional(),
|
||||
instanceCscSigning: z.boolean().optional(),
|
||||
cscQesSigning: z.boolean().optional(),
|
||||
instanceTwoFactorEnforcement: z.boolean().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -44,15 +44,27 @@ export const ZOrganisationLiteSchema = OrganisationSchema.pick({
|
||||
*/
|
||||
export const ZOrganisationManySchema = ZOrganisationLiteSchema;
|
||||
|
||||
/**
|
||||
* Metadata stored on the SSO account-link verification token.
|
||||
*
|
||||
* Intentionally stores ONLY the provider subject and the email address the
|
||||
* confirmation was sent to — never access/ID tokens. The linked account row
|
||||
* is created without provider tokens; the organisation portal re-authenticates
|
||||
* against the IdP on every sign-in, so persisting tokens in a verification
|
||||
* token row would only widen the blast radius of a database leak.
|
||||
*/
|
||||
export const ZOrganisationAccountLinkMetadataSchema = z.object({
|
||||
type: z.enum(['link', 'create']),
|
||||
userId: z.number(),
|
||||
organisationId: z.string(),
|
||||
|
||||
/**
|
||||
* The email address the confirmation email was sent to. Confirmation
|
||||
* asserts the user's email still matches this value.
|
||||
*/
|
||||
email: z.string().email(),
|
||||
oauthConfig: z.object({
|
||||
providerAccountId: z.string(),
|
||||
accessToken: z.string(),
|
||||
expiresAt: z.number(),
|
||||
idToken: z.string(),
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* The method used to authenticate a session.
|
||||
*
|
||||
* Stored as a plain string on `Session.authMethod` (deliberately not a PG
|
||||
* enum). `unknown` is the column default and covers sessions created before
|
||||
* the column existed.
|
||||
*/
|
||||
export const ZSessionAuthMethodSchema = z.enum(['email-password', 'oauth', 'passkey', 'unknown']);
|
||||
|
||||
export type TSessionAuthMethod = z.infer<typeof ZSessionAuthMethodSchema>;
|
||||
@@ -0,0 +1,76 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { isValidReturnTo, normalizeReturnTo } from '../utils/is-valid-return-to';
|
||||
import { ZSessionAuthMethodSchema } from './session-auth-method';
|
||||
|
||||
/**
|
||||
* Deferred OAuth account-link action stored in pending 2FA challenge metadata.
|
||||
*
|
||||
* When an OAuth callback would link a new provider account to an existing
|
||||
* 2FA-enabled user, NO account mutation may happen before the second factor
|
||||
* verifies. The entire link transaction is deferred: this payload carries just
|
||||
* enough to recreate it after the code passes.
|
||||
*
|
||||
* Access/ID tokens are deliberately NOT stored here — the deferred account row
|
||||
* is created without them. Challenge metadata lives in the database for up to
|
||||
* 10 minutes on the strength of primary auth alone, and provider tokens must
|
||||
* never be obtainable from a stolen challenge row.
|
||||
*/
|
||||
export const ZTwoFactorChallengeActionSchema = z
|
||||
.object({
|
||||
type: z.literal('link-oauth-account'),
|
||||
|
||||
/**
|
||||
* The OAuth provider id (e.g. 'google', 'microsoft', 'oidc').
|
||||
*/
|
||||
provider: z.string().min(1),
|
||||
|
||||
/**
|
||||
* The provider subject (`sub` claim) identifying the external account.
|
||||
*/
|
||||
providerAccountId: z.string().min(1),
|
||||
|
||||
/**
|
||||
* The user's email at the time of the OAuth callback. Execution re-checks
|
||||
* that the user's email is unchanged before linking.
|
||||
*/
|
||||
email: z.string().email(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export type TTwoFactorChallengeAction = z.infer<typeof ZTwoFactorChallengeActionSchema>;
|
||||
|
||||
/**
|
||||
* Metadata stored on a pending 2FA challenge verification token.
|
||||
*
|
||||
* Strict: unknown keys are rejected so nothing can smuggle extra state (such
|
||||
* as provider tokens) into challenge metadata.
|
||||
*/
|
||||
export const ZTwoFactorChallengeMetadataSchema = z
|
||||
.object({
|
||||
/**
|
||||
* Where to send the user after the challenge passes. Validated as a
|
||||
* same-origin path both when written and when read back.
|
||||
*/
|
||||
redirectPath: z
|
||||
.string()
|
||||
.refine((value) => isValidReturnTo(value), {
|
||||
message: 'redirectPath must be a same-origin path',
|
||||
})
|
||||
.transform((value) => normalizeReturnTo(value) || '/'),
|
||||
|
||||
/**
|
||||
* The primary authentication method that initiated this challenge. The
|
||||
* session created after the code verifies is stamped with this value.
|
||||
*/
|
||||
authMethod: ZSessionAuthMethodSchema,
|
||||
|
||||
/**
|
||||
* Optional deferred OAuth account-link action, executed in the same
|
||||
* transaction as token consumption after the code verifies.
|
||||
*/
|
||||
action: ZTwoFactorChallengeActionSchema.optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export type TTwoFactorChallengeMetadata = z.infer<typeof ZTwoFactorChallengeMetadataSchema>;
|
||||
@@ -139,5 +139,9 @@ export const generateDefaultOrganisationSettings = (): Omit<OrganisationGlobalSe
|
||||
reminderSettings: DEFAULT_ENVELOPE_REMINDER_SETTINGS,
|
||||
|
||||
aiFeaturesEnabled: false,
|
||||
|
||||
twoFactorRequired: false,
|
||||
twoFactorGracePeriodDays: 7,
|
||||
twoFactorEnforcedFrom: null,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -209,6 +209,25 @@ export const generateDefaultTeamSettings = (): Omit<TeamGlobalSettings, 'id' | '
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Settings keys that exist only on `OrganisationGlobalSettings` and have no
|
||||
* counterpart column on `TeamGlobalSettings`.
|
||||
*
|
||||
* These must be skipped when deriving team settings, otherwise
|
||||
* `teamSettings[key]` resolves to `undefined` (not `null`) and overwrites the
|
||||
* organisation value.
|
||||
*/
|
||||
const ORGANISATION_ONLY_SETTINGS_KEYS = [
|
||||
'twoFactorRequired',
|
||||
'twoFactorGracePeriodDays',
|
||||
'twoFactorEnforcedFrom',
|
||||
] as const;
|
||||
|
||||
type OrganisationOnlySettingsKey = (typeof ORGANISATION_ONLY_SETTINGS_KEYS)[number];
|
||||
|
||||
const isOrganisationOnlySettingsKey = (key: PropertyKey): key is OrganisationOnlySettingsKey =>
|
||||
ORGANISATION_ONLY_SETTINGS_KEYS.some((organisationOnlyKey) => organisationOnlyKey === key);
|
||||
|
||||
/**
|
||||
* Derive the final settings for a team.
|
||||
*
|
||||
@@ -225,6 +244,10 @@ export const extractDerivedTeamSettings = (
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
for (const key of Object.keys(derivedSettings) as (keyof typeof derivedSettings)[]) {
|
||||
if (isOrganisationOnlySettingsKey(key)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const teamValue = teamSettings[key];
|
||||
|
||||
if (teamValue !== null) {
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { ZTwoFactorChallengeMetadataSchema } from '../types/two-factor-challenge';
|
||||
import {
|
||||
isChallengeExpired,
|
||||
shouldConsumeChallengeAfterFailure,
|
||||
TWO_FACTOR_CHALLENGE_MAX_ATTEMPTS,
|
||||
} from './two-factor-challenge';
|
||||
|
||||
describe('ZTwoFactorChallengeMetadataSchema', () => {
|
||||
it('parses minimal valid metadata and normalizes the redirect path', () => {
|
||||
const result = ZTwoFactorChallengeMetadataSchema.parse({
|
||||
redirectPath: '/documents?page=2',
|
||||
authMethod: 'oauth',
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
redirectPath: '/documents?page=2',
|
||||
authMethod: 'oauth',
|
||||
});
|
||||
});
|
||||
|
||||
it('normalizes a same-origin absolute URL down to a path', () => {
|
||||
const result = ZTwoFactorChallengeMetadataSchema.parse({
|
||||
redirectPath: 'http://localhost:3000/settings/security',
|
||||
authMethod: 'oauth',
|
||||
});
|
||||
|
||||
expect(result.redirectPath).toBe('/settings/security');
|
||||
});
|
||||
|
||||
it('rejects cross-origin redirect paths', () => {
|
||||
const result = ZTwoFactorChallengeMetadataSchema.safeParse({
|
||||
redirectPath: 'https://evil.example.com/phish',
|
||||
authMethod: 'oauth',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects unknown authentication methods', () => {
|
||||
const result = ZTwoFactorChallengeMetadataSchema.safeParse({
|
||||
redirectPath: '/',
|
||||
authMethod: 'carrier-pigeon',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('accepts a deferred link action', () => {
|
||||
const result = ZTwoFactorChallengeMetadataSchema.parse({
|
||||
redirectPath: '/',
|
||||
authMethod: 'oauth',
|
||||
action: {
|
||||
type: 'link-oauth-account',
|
||||
provider: 'google',
|
||||
providerAccountId: 'subject-123',
|
||||
email: 'user@example.com',
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.action?.provider).toBe('google');
|
||||
});
|
||||
|
||||
it('rejects unknown keys on the metadata (strict)', () => {
|
||||
const result = ZTwoFactorChallengeMetadataSchema.safeParse({
|
||||
redirectPath: '/',
|
||||
authMethod: 'oauth',
|
||||
accessToken: 'must-never-be-stored',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects unknown keys on the action (strict)', () => {
|
||||
const result = ZTwoFactorChallengeMetadataSchema.safeParse({
|
||||
redirectPath: '/',
|
||||
authMethod: 'oauth',
|
||||
action: {
|
||||
type: 'link-oauth-account',
|
||||
provider: 'google',
|
||||
providerAccountId: 'subject-123',
|
||||
email: 'user@example.com',
|
||||
idToken: 'must-never-be-stored',
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects an action with an invalid email', () => {
|
||||
const result = ZTwoFactorChallengeMetadataSchema.safeParse({
|
||||
redirectPath: '/',
|
||||
authMethod: 'oauth',
|
||||
action: {
|
||||
type: 'link-oauth-account',
|
||||
provider: 'google',
|
||||
providerAccountId: 'subject-123',
|
||||
email: 'not-an-email',
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// The `>=` boundary matters: an off-by-one here would grant one extra guess
|
||||
// per challenge (or one extra valid moment past expiry).
|
||||
describe('challenge consumption boundaries', () => {
|
||||
it('consumes the challenge at exactly the maximum attempts, not before', () => {
|
||||
expect(shouldConsumeChallengeAfterFailure(TWO_FACTOR_CHALLENGE_MAX_ATTEMPTS - 1)).toBe(false);
|
||||
expect(shouldConsumeChallengeAfterFailure(TWO_FACTOR_CHALLENGE_MAX_ATTEMPTS)).toBe(true);
|
||||
});
|
||||
|
||||
it('treats the expiry instant itself as expired', () => {
|
||||
const expiresAt = new Date('2026-01-01T00:10:00.000Z');
|
||||
|
||||
expect(isChallengeExpired({ expiresAt, now: new Date('2026-01-01T00:09:59.999Z') })).toBe(false);
|
||||
expect(isChallengeExpired({ expiresAt, now: expiresAt })).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* Pure helpers for pending 2FA challenge tokens.
|
||||
*
|
||||
* Everything in this file must remain free of I/O so the challenge
|
||||
* consumption rules can be unit tested directly. The server-side
|
||||
* create/consume logic lives in `@documenso/auth`.
|
||||
*/
|
||||
|
||||
/**
|
||||
* `VerificationToken.identifier` for pending 2FA challenge tokens.
|
||||
*/
|
||||
export const TWO_FACTOR_CHALLENGE_TOKEN_IDENTIFIER = 'two-factor-challenge';
|
||||
|
||||
/**
|
||||
* How long a pending challenge remains valid after primary auth passes.
|
||||
*/
|
||||
export const TWO_FACTOR_CHALLENGE_LIFETIME_MS = 10 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Failed code attempts before the challenge is consumed and the user must
|
||||
* restart sign-in.
|
||||
*
|
||||
* The rate limiter fails open on DB errors, so this counter — atomically
|
||||
* incremented on the token row itself — is the hard bound on guesses per
|
||||
* challenge.
|
||||
*/
|
||||
export const TWO_FACTOR_CHALLENGE_MAX_ATTEMPTS = 10;
|
||||
|
||||
/**
|
||||
* Whether a challenge must be consumed after a failed attempt.
|
||||
*
|
||||
* @param attempts The attempt count AFTER the failed attempt was recorded.
|
||||
*/
|
||||
export const shouldConsumeChallengeAfterFailure = (
|
||||
attempts: number,
|
||||
maxAttempts: number = TWO_FACTOR_CHALLENGE_MAX_ATTEMPTS,
|
||||
): boolean => {
|
||||
return attempts >= maxAttempts;
|
||||
};
|
||||
|
||||
export type IsChallengeExpiredOptions = {
|
||||
expiresAt: Date;
|
||||
now: Date;
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether a challenge has expired. The expiry instant itself counts as
|
||||
* expired (`now >= expiresAt`).
|
||||
*/
|
||||
export const isChallengeExpired = (options: IsChallengeExpiredOptions): boolean => {
|
||||
const { expiresAt, now } = options;
|
||||
|
||||
return now.getTime() >= expiresAt.getTime();
|
||||
};
|
||||
@@ -0,0 +1,718 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
calculateTwoFactorDeadline,
|
||||
calculateTwoFactorDeadlineTimerDelay,
|
||||
computeOrganisationTwoFactorEnforcementStatus,
|
||||
computeTwoFactorEnforcementStatus,
|
||||
evaluateInstanceTwoFactorEnforcementUpdate,
|
||||
isInstanceTwoFactorEnforcementActive,
|
||||
isTwoFactorGracePeriodReduction,
|
||||
MAX_TWO_FACTOR_DEADLINE_TIMER_DELAY_MS,
|
||||
} from './two-factor';
|
||||
|
||||
describe('calculateTwoFactorDeadline', () => {
|
||||
it('adds the grace period to a single anchor', () => {
|
||||
const anchor = new Date('2026-01-01T00:00:00.000Z');
|
||||
|
||||
const deadline = calculateTwoFactorDeadline({ anchors: [anchor], gracePeriodDays: 7 });
|
||||
|
||||
expect(deadline).toEqual(new Date('2026-01-08T00:00:00.000Z'));
|
||||
});
|
||||
|
||||
it('uses the latest anchor when multiple are provided', () => {
|
||||
const earlier = new Date('2026-01-01T00:00:00.000Z');
|
||||
const latest = new Date('2026-02-01T00:00:00.000Z');
|
||||
|
||||
const deadline = calculateTwoFactorDeadline({
|
||||
anchors: [earlier, latest],
|
||||
gracePeriodDays: 1,
|
||||
});
|
||||
|
||||
expect(deadline).toEqual(new Date('2026-02-02T00:00:00.000Z'));
|
||||
});
|
||||
|
||||
it('ignores null and undefined anchors', () => {
|
||||
const anchor = new Date('2026-01-01T00:00:00.000Z');
|
||||
|
||||
const deadline = calculateTwoFactorDeadline({
|
||||
anchors: [null, anchor, undefined],
|
||||
gracePeriodDays: 0,
|
||||
});
|
||||
|
||||
expect(deadline).toEqual(anchor);
|
||||
});
|
||||
|
||||
it('returns the anchor instant for a 0 day grace period', () => {
|
||||
const anchor = new Date('2026-01-01T12:34:56.000Z');
|
||||
|
||||
const deadline = calculateTwoFactorDeadline({ anchors: [anchor], gracePeriodDays: 0 });
|
||||
|
||||
expect(deadline).toEqual(anchor);
|
||||
});
|
||||
|
||||
it('returns null when no anchors are provided', () => {
|
||||
expect(calculateTwoFactorDeadline({ anchors: [], gracePeriodDays: 7 })).toBeNull();
|
||||
expect(calculateTwoFactorDeadline({ anchors: [null, undefined], gracePeriodDays: 7 })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('isTwoFactorGracePeriodReduction', () => {
|
||||
const anchor = new Date('2026-01-01T00:00:00.000Z');
|
||||
const now = new Date('2026-01-02T00:00:00.000Z');
|
||||
|
||||
it('detects a reduced grace period while the previous grace is active', () => {
|
||||
expect(
|
||||
isTwoFactorGracePeriodReduction({
|
||||
previous: { anchors: [anchor], gracePeriodDays: 30 },
|
||||
next: { anchors: [anchor], gracePeriodDays: 7 },
|
||||
now,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('detects a reduction caused by an earlier anchor set', () => {
|
||||
const laterEnforcedFrom = new Date('2026-01-10T00:00:00.000Z');
|
||||
|
||||
expect(
|
||||
isTwoFactorGracePeriodReduction({
|
||||
previous: { anchors: [anchor, laterEnforcedFrom], gracePeriodDays: 7 },
|
||||
next: { anchors: [anchor], gracePeriodDays: 7 },
|
||||
now,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('is not a reduction when the deadline stays the same', () => {
|
||||
expect(
|
||||
isTwoFactorGracePeriodReduction({
|
||||
previous: { anchors: [anchor], gracePeriodDays: 7 },
|
||||
next: { anchors: [anchor], gracePeriodDays: 7 },
|
||||
now,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('is not a reduction when the deadline moves later', () => {
|
||||
expect(
|
||||
isTwoFactorGracePeriodReduction({
|
||||
previous: { anchors: [anchor], gracePeriodDays: 7 },
|
||||
next: { anchors: [anchor], gracePeriodDays: 30 },
|
||||
now,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('is not a reduction when the previous grace has already expired', () => {
|
||||
const nowAfterExpiry = new Date('2026-03-01T00:00:00.000Z');
|
||||
|
||||
expect(
|
||||
isTwoFactorGracePeriodReduction({
|
||||
previous: { anchors: [anchor], gracePeriodDays: 7 },
|
||||
next: { anchors: [anchor], gracePeriodDays: 0 },
|
||||
now: nowAfterExpiry,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('is not a reduction when enforcement was not previously configured', () => {
|
||||
expect(
|
||||
isTwoFactorGracePeriodReduction({
|
||||
previous: null,
|
||||
next: { anchors: [anchor], gracePeriodDays: 0 },
|
||||
now,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('is not a reduction when the update disables enforcement', () => {
|
||||
expect(
|
||||
isTwoFactorGracePeriodReduction({
|
||||
previous: { anchors: [anchor], gracePeriodDays: 7 },
|
||||
next: null,
|
||||
now,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('computeTwoFactorEnforcementStatus', () => {
|
||||
const anchor = new Date('2026-01-01T00:00:00.000Z');
|
||||
const deadline = new Date('2026-01-08T00:00:00.000Z');
|
||||
|
||||
const graceWindow = { anchors: [anchor], gracePeriodDays: 7 };
|
||||
|
||||
it('is not required when no grace window is configured', () => {
|
||||
expect(
|
||||
computeTwoFactorEnforcementStatus({
|
||||
graceWindow: null,
|
||||
userTwoFactorEnabled: false,
|
||||
sessionTwoFactorVerified: false,
|
||||
now: new Date('2026-01-01T00:00:00.000Z'),
|
||||
}),
|
||||
).toEqual({ required: false });
|
||||
});
|
||||
|
||||
it('is not required when the grace window has no anchors', () => {
|
||||
expect(
|
||||
computeTwoFactorEnforcementStatus({
|
||||
graceWindow: { anchors: [null, undefined], gracePeriodDays: 7 },
|
||||
userTwoFactorEnabled: false,
|
||||
sessionTwoFactorVerified: false,
|
||||
now: new Date('2026-01-01T00:00:00.000Z'),
|
||||
}),
|
||||
).toEqual({ required: false });
|
||||
});
|
||||
|
||||
it('is required but not blocked within grace while unsatisfied', () => {
|
||||
expect(
|
||||
computeTwoFactorEnforcementStatus({
|
||||
graceWindow,
|
||||
userTwoFactorEnabled: false,
|
||||
sessionTwoFactorVerified: false,
|
||||
now: new Date('2026-01-02T00:00:00.000Z'),
|
||||
}),
|
||||
).toEqual({
|
||||
required: true,
|
||||
deadline,
|
||||
isDeadlineExpired: false,
|
||||
isSatisfied: false,
|
||||
isBlocked: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('is required and satisfied within grace when enrolled and verified', () => {
|
||||
expect(
|
||||
computeTwoFactorEnforcementStatus({
|
||||
graceWindow,
|
||||
userTwoFactorEnabled: true,
|
||||
sessionTwoFactorVerified: true,
|
||||
now: new Date('2026-01-02T00:00:00.000Z'),
|
||||
}),
|
||||
).toEqual({
|
||||
required: true,
|
||||
deadline,
|
||||
isDeadlineExpired: false,
|
||||
isSatisfied: true,
|
||||
isBlocked: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('blocks after the deadline while unsatisfied', () => {
|
||||
expect(
|
||||
computeTwoFactorEnforcementStatus({
|
||||
graceWindow,
|
||||
userTwoFactorEnabled: false,
|
||||
sessionTwoFactorVerified: false,
|
||||
now: new Date('2026-02-01T00:00:00.000Z'),
|
||||
}),
|
||||
).toEqual({
|
||||
required: true,
|
||||
deadline,
|
||||
isDeadlineExpired: true,
|
||||
isSatisfied: false,
|
||||
isBlocked: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('does not block after the deadline when satisfied', () => {
|
||||
expect(
|
||||
computeTwoFactorEnforcementStatus({
|
||||
graceWindow,
|
||||
userTwoFactorEnabled: true,
|
||||
sessionTwoFactorVerified: true,
|
||||
now: new Date('2026-02-01T00:00:00.000Z'),
|
||||
}),
|
||||
).toEqual({
|
||||
required: true,
|
||||
deadline,
|
||||
isDeadlineExpired: true,
|
||||
isSatisfied: true,
|
||||
isBlocked: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('does not block when enrolled but the session is unverified within grace', () => {
|
||||
const status = computeTwoFactorEnforcementStatus({
|
||||
graceWindow,
|
||||
userTwoFactorEnabled: true,
|
||||
sessionTwoFactorVerified: false,
|
||||
now: new Date('2026-01-02T00:00:00.000Z'),
|
||||
});
|
||||
|
||||
expect(status).toMatchObject({ required: true, isSatisfied: false, isBlocked: false });
|
||||
});
|
||||
|
||||
it('blocks an enrolled user with an unverified session after the deadline', () => {
|
||||
const status = computeTwoFactorEnforcementStatus({
|
||||
graceWindow,
|
||||
userTwoFactorEnabled: true,
|
||||
sessionTwoFactorVerified: false,
|
||||
now: new Date('2026-02-01T00:00:00.000Z'),
|
||||
});
|
||||
|
||||
expect(status).toMatchObject({ required: true, isSatisfied: false, isBlocked: true });
|
||||
});
|
||||
|
||||
it('counts the deadline instant itself as expired', () => {
|
||||
const status = computeTwoFactorEnforcementStatus({
|
||||
graceWindow,
|
||||
userTwoFactorEnabled: false,
|
||||
sessionTwoFactorVerified: false,
|
||||
now: deadline,
|
||||
});
|
||||
|
||||
expect(status).toMatchObject({ required: true, isDeadlineExpired: true, isBlocked: true });
|
||||
});
|
||||
|
||||
it('is not expired just before the deadline instant', () => {
|
||||
const status = computeTwoFactorEnforcementStatus({
|
||||
graceWindow,
|
||||
userTwoFactorEnabled: false,
|
||||
sessionTwoFactorVerified: false,
|
||||
now: new Date(deadline.getTime() - 1),
|
||||
});
|
||||
|
||||
expect(status).toMatchObject({ required: true, isDeadlineExpired: false, isBlocked: false });
|
||||
});
|
||||
|
||||
it('uses the latest anchor for the deadline', () => {
|
||||
const laterEnforcedFrom = new Date('2026-01-10T00:00:00.000Z');
|
||||
|
||||
const status = computeTwoFactorEnforcementStatus({
|
||||
graceWindow: { anchors: [anchor, laterEnforcedFrom], gracePeriodDays: 7 },
|
||||
userTwoFactorEnabled: false,
|
||||
sessionTwoFactorVerified: false,
|
||||
now: new Date('2026-01-09T00:00:00.000Z'),
|
||||
});
|
||||
|
||||
expect(status).toMatchObject({
|
||||
required: true,
|
||||
deadline: new Date('2026-01-17T00:00:00.000Z'),
|
||||
isDeadlineExpired: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('blocks immediately with a 0 day grace period', () => {
|
||||
const status = computeTwoFactorEnforcementStatus({
|
||||
graceWindow: { anchors: [anchor], gracePeriodDays: 0 },
|
||||
userTwoFactorEnabled: false,
|
||||
sessionTwoFactorVerified: false,
|
||||
now: anchor,
|
||||
});
|
||||
|
||||
expect(status).toMatchObject({ required: true, deadline: anchor, isBlocked: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('isInstanceTwoFactorEnforcementActive', () => {
|
||||
it('is active when the setting exists, is enabled and the license grants the flag', () => {
|
||||
expect(isInstanceTwoFactorEnforcementActive({ setting: { enabled: true }, isLicensed: true })).toBe(true);
|
||||
});
|
||||
|
||||
it('is inactive when the setting row is missing', () => {
|
||||
expect(isInstanceTwoFactorEnforcementActive({ setting: null, isLicensed: true })).toBe(false);
|
||||
});
|
||||
|
||||
it('is inactive when the setting is disabled', () => {
|
||||
expect(isInstanceTwoFactorEnforcementActive({ setting: { enabled: false }, isLicensed: true })).toBe(false);
|
||||
});
|
||||
|
||||
it('is inactive on an unlicensed instance even when a row is enabled', () => {
|
||||
expect(isInstanceTwoFactorEnforcementActive({ setting: { enabled: true }, isLicensed: false })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('computeOrganisationTwoFactorEnforcementStatus', () => {
|
||||
const memberCreatedAt = new Date('2026-01-01T00:00:00.000Z');
|
||||
const graceStartedAt = new Date('2025-12-01T00:00:00.000Z');
|
||||
|
||||
const baseOptions = {
|
||||
memberCreatedAt,
|
||||
userTwoFactorEnabled: false,
|
||||
userTwoFactorGraceStartedAt: graceStartedAt,
|
||||
sessionTwoFactorVerified: false,
|
||||
};
|
||||
|
||||
it('is not required when the organisation does not require 2FA', () => {
|
||||
const status = computeOrganisationTwoFactorEnforcementStatus({
|
||||
...baseOptions,
|
||||
organisationSettings: {
|
||||
twoFactorRequired: false,
|
||||
twoFactorGracePeriodDays: 7,
|
||||
twoFactorEnforcedFrom: new Date('2026-01-05T00:00:00.000Z'),
|
||||
},
|
||||
now: new Date('2026-12-01T00:00:00.000Z'),
|
||||
});
|
||||
|
||||
expect(status).toEqual({ required: false });
|
||||
});
|
||||
|
||||
it('derives the deadline from max(member.createdAt, enforcedFrom, graceStartedAt) + gracePeriodDays', () => {
|
||||
const enforcedFrom = new Date('2026-01-10T00:00:00.000Z');
|
||||
|
||||
const status = computeOrganisationTwoFactorEnforcementStatus({
|
||||
...baseOptions,
|
||||
organisationSettings: {
|
||||
twoFactorRequired: true,
|
||||
twoFactorGracePeriodDays: 7,
|
||||
twoFactorEnforcedFrom: enforcedFrom,
|
||||
},
|
||||
now: new Date('2026-01-12T00:00:00.000Z'),
|
||||
});
|
||||
|
||||
expect(status).toMatchObject({
|
||||
required: true,
|
||||
deadline: new Date('2026-01-17T00:00:00.000Z'),
|
||||
isDeadlineExpired: false,
|
||||
isBlocked: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('anchors on member.createdAt when enforcedFrom is null and grace started earlier', () => {
|
||||
const status = computeOrganisationTwoFactorEnforcementStatus({
|
||||
...baseOptions,
|
||||
organisationSettings: {
|
||||
twoFactorRequired: true,
|
||||
twoFactorGracePeriodDays: 7,
|
||||
twoFactorEnforcedFrom: null,
|
||||
},
|
||||
now: new Date('2026-01-08T00:00:00.000Z'),
|
||||
});
|
||||
|
||||
expect(status).toMatchObject({
|
||||
required: true,
|
||||
deadline: new Date('2026-01-08T00:00:00.000Z'),
|
||||
isDeadlineExpired: true,
|
||||
isBlocked: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('an admin 2FA reset (later graceStartedAt) restarts the organisation grace window', () => {
|
||||
const restartedGraceStartedAt = new Date('2026-02-01T00:00:00.000Z');
|
||||
|
||||
const status = computeOrganisationTwoFactorEnforcementStatus({
|
||||
...baseOptions,
|
||||
userTwoFactorGraceStartedAt: restartedGraceStartedAt,
|
||||
organisationSettings: {
|
||||
twoFactorRequired: true,
|
||||
twoFactorGracePeriodDays: 7,
|
||||
twoFactorEnforcedFrom: null,
|
||||
},
|
||||
now: new Date('2026-02-02T00:00:00.000Z'),
|
||||
});
|
||||
|
||||
expect(status).toMatchObject({
|
||||
required: true,
|
||||
deadline: new Date('2026-02-08T00:00:00.000Z'),
|
||||
isDeadlineExpired: false,
|
||||
isBlocked: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('is satisfied (never blocked) for an enrolled user with a verified session', () => {
|
||||
const status = computeOrganisationTwoFactorEnforcementStatus({
|
||||
...baseOptions,
|
||||
userTwoFactorEnabled: true,
|
||||
sessionTwoFactorVerified: true,
|
||||
organisationSettings: {
|
||||
twoFactorRequired: true,
|
||||
twoFactorGracePeriodDays: 0,
|
||||
twoFactorEnforcedFrom: null,
|
||||
},
|
||||
now: new Date('2027-01-01T00:00:00.000Z'),
|
||||
});
|
||||
|
||||
expect(status).toMatchObject({
|
||||
required: true,
|
||||
isSatisfied: true,
|
||||
isDeadlineExpired: true,
|
||||
isBlocked: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('an enrolled user with an unverified session (null/API context) is not satisfied', () => {
|
||||
const status = computeOrganisationTwoFactorEnforcementStatus({
|
||||
...baseOptions,
|
||||
userTwoFactorEnabled: true,
|
||||
sessionTwoFactorVerified: false,
|
||||
organisationSettings: {
|
||||
twoFactorRequired: true,
|
||||
twoFactorGracePeriodDays: 0,
|
||||
twoFactorEnforcedFrom: null,
|
||||
},
|
||||
now: new Date('2027-01-01T00:00:00.000Z'),
|
||||
});
|
||||
|
||||
expect(status).toMatchObject({
|
||||
required: true,
|
||||
isSatisfied: false,
|
||||
isBlocked: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('blocks organisation access immediately with a 0 day grace period', () => {
|
||||
const status = computeOrganisationTwoFactorEnforcementStatus({
|
||||
...baseOptions,
|
||||
organisationSettings: {
|
||||
twoFactorRequired: true,
|
||||
twoFactorGracePeriodDays: 0,
|
||||
twoFactorEnforcedFrom: null,
|
||||
},
|
||||
now: memberCreatedAt,
|
||||
});
|
||||
|
||||
expect(status).toMatchObject({ required: true, deadline: memberCreatedAt, isBlocked: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('evaluateInstanceTwoFactorEnforcementUpdate', () => {
|
||||
const now = new Date('2026-06-01T00:00:00.000Z');
|
||||
|
||||
const satisfiedActor = { userTwoFactorEnabled: true, sessionTwoFactorVerified: true };
|
||||
const unsatisfiedActor = { userTwoFactorEnabled: false, sessionTwoFactorVerified: false };
|
||||
|
||||
const storedDisabled = { enabled: false, gracePeriodDays: 7, enforcedFrom: null };
|
||||
|
||||
// Enabled with an active grace window: enforcedFrom 1 day ago + 30 days.
|
||||
const storedEnabledActiveGrace = {
|
||||
enabled: true,
|
||||
gracePeriodDays: 30,
|
||||
enforcedFrom: '2026-05-31T00:00:00.000Z',
|
||||
};
|
||||
|
||||
describe('license gate', () => {
|
||||
it('rejects enabling on an unlicensed instance', () => {
|
||||
const decision = evaluateInstanceTwoFactorEnforcementUpdate({
|
||||
stored: storedDisabled,
|
||||
update: { enabled: true, gracePeriodDays: 7 },
|
||||
isLicensed: false,
|
||||
actor: satisfiedActor,
|
||||
now,
|
||||
});
|
||||
|
||||
expect(decision).toEqual({ allowed: false, reason: 'UNLICENSED' });
|
||||
});
|
||||
|
||||
it('rejects changing values while enabled on an unlicensed instance', () => {
|
||||
const decision = evaluateInstanceTwoFactorEnforcementUpdate({
|
||||
stored: storedEnabledActiveGrace,
|
||||
update: { enabled: true, gracePeriodDays: 60 },
|
||||
isLicensed: false,
|
||||
actor: satisfiedActor,
|
||||
now,
|
||||
});
|
||||
|
||||
expect(decision).toEqual({ allowed: false, reason: 'UNLICENSED' });
|
||||
});
|
||||
|
||||
it('rejects an unlicensed disabled→disabled no-op write', () => {
|
||||
const decision = evaluateInstanceTwoFactorEnforcementUpdate({
|
||||
stored: storedDisabled,
|
||||
update: { enabled: false, gracePeriodDays: 7 },
|
||||
isLicensed: false,
|
||||
actor: satisfiedActor,
|
||||
now,
|
||||
});
|
||||
|
||||
expect(decision).toEqual({ allowed: false, reason: 'UNLICENSED' });
|
||||
});
|
||||
|
||||
it('rejects an unlicensed disable that also changes the grace period', () => {
|
||||
const decision = evaluateInstanceTwoFactorEnforcementUpdate({
|
||||
stored: storedEnabledActiveGrace,
|
||||
update: { enabled: false, gracePeriodDays: 60 },
|
||||
isLicensed: false,
|
||||
actor: satisfiedActor,
|
||||
now,
|
||||
});
|
||||
|
||||
expect(decision).toEqual({ allowed: false, reason: 'UNLICENSED' });
|
||||
});
|
||||
|
||||
it('allows an unlicensed disable-only update with values unchanged from stored', () => {
|
||||
const decision = evaluateInstanceTwoFactorEnforcementUpdate({
|
||||
stored: storedEnabledActiveGrace,
|
||||
update: { enabled: false, gracePeriodDays: storedEnabledActiveGrace.gracePeriodDays },
|
||||
isLicensed: false,
|
||||
// Even an unenrolled admin may disable — walking the policy back must
|
||||
// never be gated on satisfying it.
|
||||
actor: unsatisfiedActor,
|
||||
now,
|
||||
});
|
||||
|
||||
expect(decision).toEqual({
|
||||
allowed: true,
|
||||
next: {
|
||||
enabled: false,
|
||||
gracePeriodDays: storedEnabledActiveGrace.gracePeriodDays,
|
||||
enforcedFrom: storedEnabledActiveGrace.enforcedFrom,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('enable-time guard', () => {
|
||||
it('rejects enabling when the acting admin does not satisfy the policy', () => {
|
||||
const decision = evaluateInstanceTwoFactorEnforcementUpdate({
|
||||
stored: storedDisabled,
|
||||
update: { enabled: true, gracePeriodDays: 0 },
|
||||
isLicensed: true,
|
||||
actor: unsatisfiedActor,
|
||||
now,
|
||||
});
|
||||
|
||||
expect(decision).toEqual({ allowed: false, reason: 'ENABLE_REQUIRES_ACTOR_TWO_FACTOR' });
|
||||
});
|
||||
|
||||
it('rejects enabling when the actor is enrolled but the session is unverified', () => {
|
||||
const decision = evaluateInstanceTwoFactorEnforcementUpdate({
|
||||
stored: storedDisabled,
|
||||
update: { enabled: true, gracePeriodDays: 7 },
|
||||
isLicensed: true,
|
||||
actor: { userTwoFactorEnabled: true, sessionTwoFactorVerified: false },
|
||||
now,
|
||||
});
|
||||
|
||||
expect(decision).toEqual({ allowed: false, reason: 'ENABLE_REQUIRES_ACTOR_TWO_FACTOR' });
|
||||
});
|
||||
|
||||
it('does not apply the guard when updating values while already enabled', () => {
|
||||
const decision = evaluateInstanceTwoFactorEnforcementUpdate({
|
||||
stored: storedEnabledActiveGrace,
|
||||
update: { enabled: true, gracePeriodDays: 60 },
|
||||
isLicensed: true,
|
||||
actor: unsatisfiedActor,
|
||||
now,
|
||||
});
|
||||
|
||||
expect(decision).toMatchObject({ allowed: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('enforcedFrom handling', () => {
|
||||
it('sets enforcedFrom to now on an off→on transition', () => {
|
||||
const decision = evaluateInstanceTwoFactorEnforcementUpdate({
|
||||
stored: storedDisabled,
|
||||
update: { enabled: true, gracePeriodDays: 14 },
|
||||
isLicensed: true,
|
||||
actor: satisfiedActor,
|
||||
now,
|
||||
});
|
||||
|
||||
expect(decision).toEqual({
|
||||
allowed: true,
|
||||
next: { enabled: true, gracePeriodDays: 14, enforcedFrom: now.toISOString() },
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves enforcedFrom when the policy stays enabled', () => {
|
||||
const decision = evaluateInstanceTwoFactorEnforcementUpdate({
|
||||
stored: storedEnabledActiveGrace,
|
||||
update: { enabled: true, gracePeriodDays: 60 },
|
||||
isLicensed: true,
|
||||
actor: satisfiedActor,
|
||||
now,
|
||||
});
|
||||
|
||||
expect(decision).toEqual({
|
||||
allowed: true,
|
||||
next: { enabled: true, gracePeriodDays: 60, enforcedFrom: storedEnabledActiveGrace.enforcedFrom },
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves enforcedFrom on disable', () => {
|
||||
const decision = evaluateInstanceTwoFactorEnforcementUpdate({
|
||||
stored: storedEnabledActiveGrace,
|
||||
update: { enabled: false, gracePeriodDays: storedEnabledActiveGrace.gracePeriodDays },
|
||||
isLicensed: true,
|
||||
actor: satisfiedActor,
|
||||
now,
|
||||
});
|
||||
|
||||
expect(decision).toEqual({
|
||||
allowed: true,
|
||||
next: {
|
||||
enabled: false,
|
||||
gracePeriodDays: storedEnabledActiveGrace.gracePeriodDays,
|
||||
enforcedFrom: storedEnabledActiveGrace.enforcedFrom,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('grace-reduction acknowledgement', () => {
|
||||
it('rejects reducing an active grace period without acknowledgement', () => {
|
||||
const decision = evaluateInstanceTwoFactorEnforcementUpdate({
|
||||
stored: storedEnabledActiveGrace,
|
||||
update: { enabled: true, gracePeriodDays: 1 },
|
||||
isLicensed: true,
|
||||
actor: satisfiedActor,
|
||||
now,
|
||||
});
|
||||
|
||||
expect(decision).toEqual({ allowed: false, reason: 'GRACE_REDUCTION_NOT_ACKNOWLEDGED' });
|
||||
});
|
||||
|
||||
it('allows reducing an active grace period with acknowledgement', () => {
|
||||
const decision = evaluateInstanceTwoFactorEnforcementUpdate({
|
||||
stored: storedEnabledActiveGrace,
|
||||
update: { enabled: true, gracePeriodDays: 1, acknowledgeGracePeriodReduction: true },
|
||||
isLicensed: true,
|
||||
actor: satisfiedActor,
|
||||
now,
|
||||
});
|
||||
|
||||
expect(decision).toEqual({
|
||||
allowed: true,
|
||||
next: { enabled: true, gracePeriodDays: 1, enforcedFrom: storedEnabledActiveGrace.enforcedFrom },
|
||||
});
|
||||
});
|
||||
|
||||
it('does not require acknowledgement when enabling for the first time', () => {
|
||||
const decision = evaluateInstanceTwoFactorEnforcementUpdate({
|
||||
stored: storedDisabled,
|
||||
update: { enabled: true, gracePeriodDays: 0 },
|
||||
isLicensed: true,
|
||||
actor: satisfiedActor,
|
||||
now,
|
||||
});
|
||||
|
||||
expect(decision).toMatchObject({ allowed: true });
|
||||
});
|
||||
|
||||
it('does not require acknowledgement when tightening an already-expired window', () => {
|
||||
const decision = evaluateInstanceTwoFactorEnforcementUpdate({
|
||||
stored: {
|
||||
enabled: true,
|
||||
gracePeriodDays: 7,
|
||||
enforcedFrom: '2026-01-01T00:00:00.000Z',
|
||||
},
|
||||
update: { enabled: true, gracePeriodDays: 0 },
|
||||
isLicensed: true,
|
||||
actor: satisfiedActor,
|
||||
now,
|
||||
});
|
||||
|
||||
expect(decision).toMatchObject({ allowed: true });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('calculateTwoFactorDeadlineTimerDelay', () => {
|
||||
const now = new Date('2026-06-01T00:00:00.000Z');
|
||||
|
||||
it('returns 0 for a past deadline', () => {
|
||||
const deadline = new Date(now.getTime() - 60_000);
|
||||
|
||||
expect(calculateTwoFactorDeadlineTimerDelay({ deadline, now })).toBe(0);
|
||||
});
|
||||
|
||||
it('returns the delay at exactly the setTimeout maximum and null just past it', () => {
|
||||
const atMax = new Date(now.getTime() + MAX_TWO_FACTOR_DEADLINE_TIMER_DELAY_MS);
|
||||
const pastMax = new Date(now.getTime() + MAX_TWO_FACTOR_DEADLINE_TIMER_DELAY_MS + 1);
|
||||
|
||||
expect(calculateTwoFactorDeadlineTimerDelay({ deadline: atMax, now })).toBe(MAX_TWO_FACTOR_DEADLINE_TIMER_DELAY_MS);
|
||||
expect(calculateTwoFactorDeadlineTimerDelay({ deadline: pastMax, now })).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,449 @@
|
||||
/**
|
||||
* Pure 2FA enforcement policy helpers.
|
||||
*
|
||||
* Everything in this file must remain free of I/O so the enforcement policy
|
||||
* can be unit tested directly.
|
||||
*/
|
||||
|
||||
const MILLISECONDS_PER_DAY = 24 * 60 * 60 * 1000;
|
||||
|
||||
export type IsTwoFactorSatisfiedOptions = {
|
||||
userTwoFactorEnabled: boolean;
|
||||
sessionTwoFactorVerified: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether a user + session pair satisfies 2FA enforcement.
|
||||
*
|
||||
* The user must have 2FA enrolled AND the current session must have passed a
|
||||
* second factor (TOTP/backup challenge, UV passkey sign-in, or enabling 2FA
|
||||
* mid-session).
|
||||
*/
|
||||
export const isTwoFactorSatisfied = (options: IsTwoFactorSatisfiedOptions): boolean => {
|
||||
const { userTwoFactorEnabled, sessionTwoFactorVerified } = options;
|
||||
|
||||
return userTwoFactorEnabled && sessionTwoFactorVerified;
|
||||
};
|
||||
|
||||
export type CalculateTwoFactorDeadlineOptions = {
|
||||
/**
|
||||
* Anchor dates for the grace window. Null/undefined entries are ignored,
|
||||
* the latest remaining anchor wins.
|
||||
*/
|
||||
anchors: Array<Date | null | undefined>;
|
||||
|
||||
/**
|
||||
* Number of grace days after the latest anchor. 0 means the deadline is the
|
||||
* anchor instant itself (immediate enforcement).
|
||||
*/
|
||||
gracePeriodDays: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Shared deadline calculation for both instance and organisation enforcement:
|
||||
* `max(anchors) + gracePeriodDays`.
|
||||
*
|
||||
* Returns `null` when no anchor is provided.
|
||||
*/
|
||||
export const calculateTwoFactorDeadline = (options: CalculateTwoFactorDeadlineOptions): Date | null => {
|
||||
const { anchors, gracePeriodDays } = options;
|
||||
|
||||
const anchorTimes = anchors
|
||||
.filter((anchor): anchor is Date => anchor instanceof Date)
|
||||
.map((anchor) => anchor.getTime());
|
||||
|
||||
if (anchorTimes.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const latestAnchorTime = Math.max(...anchorTimes);
|
||||
|
||||
return new Date(latestAnchorTime + gracePeriodDays * MILLISECONDS_PER_DAY);
|
||||
};
|
||||
|
||||
export type IsTwoFactorDeadlineExpiredOptions = {
|
||||
deadline: Date;
|
||||
now: Date;
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether a deadline has expired. The deadline instant itself counts as
|
||||
* expired (`now >= deadline`).
|
||||
*/
|
||||
export const isTwoFactorDeadlineExpired = (options: IsTwoFactorDeadlineExpiredOptions): boolean => {
|
||||
const { deadline, now } = options;
|
||||
|
||||
return now.getTime() >= deadline.getTime();
|
||||
};
|
||||
|
||||
/**
|
||||
* The maximum delay `setTimeout` reliably supports (2^31 - 1 ms, ~24.8 days).
|
||||
* Longer delays overflow the signed 32-bit timer and fire immediately.
|
||||
*/
|
||||
export const MAX_TWO_FACTOR_DEADLINE_TIMER_DELAY_MS = 2 ** 31 - 1;
|
||||
|
||||
export type CalculateTwoFactorDeadlineTimerDelayOptions = {
|
||||
deadline: Date;
|
||||
now: Date;
|
||||
};
|
||||
|
||||
/**
|
||||
* Milliseconds until a 2FA enforcement deadline, for scheduling a client-side
|
||||
* timer that navigates to enrolment the instant the grace window closes.
|
||||
*
|
||||
* Returns:
|
||||
* - `0` when the deadline is already reached/past (fire immediately),
|
||||
* - the positive delay when it fits within the `setTimeout` range,
|
||||
* - `null` when the deadline is further away than `setTimeout` can represent —
|
||||
* no timer should be scheduled (a later navigation/session refresh will
|
||||
* re-evaluate long before ~24.8 days elapse).
|
||||
*/
|
||||
export const calculateTwoFactorDeadlineTimerDelay = (
|
||||
options: CalculateTwoFactorDeadlineTimerDelayOptions,
|
||||
): number | null => {
|
||||
const { deadline, now } = options;
|
||||
|
||||
const delay = deadline.getTime() - now.getTime();
|
||||
|
||||
if (delay <= 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (delay > MAX_TWO_FACTOR_DEADLINE_TIMER_DELAY_MS) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return delay;
|
||||
};
|
||||
|
||||
export type TwoFactorGraceWindow = {
|
||||
anchors: Array<Date | null | undefined>;
|
||||
gracePeriodDays: number;
|
||||
};
|
||||
|
||||
export type IsTwoFactorGracePeriodReductionOptions = {
|
||||
/**
|
||||
* The currently stored grace window, or null when enforcement is not
|
||||
* currently configured.
|
||||
*/
|
||||
previous: TwoFactorGraceWindow | null;
|
||||
|
||||
/**
|
||||
* The proposed grace window, or null when the update disables enforcement.
|
||||
*/
|
||||
next: TwoFactorGraceWindow | null;
|
||||
|
||||
now: Date;
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether a settings update reduces an *active* grace window.
|
||||
*
|
||||
* Only true when the previous configuration produced a deadline that has not
|
||||
* yet expired and the new configuration moves that deadline earlier. Enabling
|
||||
* enforcement for the first time, disabling it, or tightening an
|
||||
* already-expired window are not reductions.
|
||||
*/
|
||||
export const isTwoFactorGracePeriodReduction = (options: IsTwoFactorGracePeriodReductionOptions): boolean => {
|
||||
const { previous, next, now } = options;
|
||||
|
||||
const previousDeadline = previous ? calculateTwoFactorDeadline(previous) : null;
|
||||
const nextDeadline = next ? calculateTwoFactorDeadline(next) : null;
|
||||
|
||||
if (!previousDeadline || !nextDeadline) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const isPreviousGraceActive = !isTwoFactorDeadlineExpired({ deadline: previousDeadline, now });
|
||||
|
||||
return isPreviousGraceActive && nextDeadline.getTime() < previousDeadline.getTime();
|
||||
};
|
||||
|
||||
/**
|
||||
* Shared 2FA enforcement status shape, used by both instance and organisation
|
||||
* enforcement.
|
||||
*
|
||||
* `isBlocked` is computed server-side once (`isDeadlineExpired && !isSatisfied`)
|
||||
* — consumers branch only on `isBlocked`; the deadline fields exist for
|
||||
* banners/copy.
|
||||
*
|
||||
* This type lives here (not in server-only code) so client code such as the
|
||||
* session provider can import it.
|
||||
*/
|
||||
export type TTwoFactorEnforcementStatus =
|
||||
| { required: false }
|
||||
| {
|
||||
required: true;
|
||||
deadline: Date;
|
||||
isDeadlineExpired: boolean;
|
||||
isSatisfied: boolean;
|
||||
isBlocked: boolean;
|
||||
};
|
||||
|
||||
export type ComputeTwoFactorEnforcementStatusOptions = {
|
||||
/**
|
||||
* The grace window derived from the applicable enforcement policy, or null
|
||||
* when enforcement is not configured/active.
|
||||
*/
|
||||
graceWindow: TwoFactorGraceWindow | null;
|
||||
|
||||
userTwoFactorEnabled: boolean;
|
||||
|
||||
/**
|
||||
* Whether the current session passed a second factor. Contexts without a
|
||||
* session (e.g. API access) never count as verified.
|
||||
*/
|
||||
sessionTwoFactorVerified: boolean;
|
||||
|
||||
now: Date;
|
||||
};
|
||||
|
||||
/**
|
||||
* Pure derivation of the 2FA enforcement status for a user + session against
|
||||
* a grace window.
|
||||
*
|
||||
* Server-only getters wrap this with the I/O needed to load the policy; the
|
||||
* policy math itself stays unit-testable.
|
||||
*/
|
||||
export const computeTwoFactorEnforcementStatus = (
|
||||
options: ComputeTwoFactorEnforcementStatusOptions,
|
||||
): TTwoFactorEnforcementStatus => {
|
||||
const { graceWindow, userTwoFactorEnabled, sessionTwoFactorVerified, now } = options;
|
||||
|
||||
if (!graceWindow) {
|
||||
return { required: false };
|
||||
}
|
||||
|
||||
const deadline = calculateTwoFactorDeadline(graceWindow);
|
||||
|
||||
// Defensive: a window without any anchor has no enforceable deadline.
|
||||
if (!deadline) {
|
||||
return { required: false };
|
||||
}
|
||||
|
||||
const isSatisfied = isTwoFactorSatisfied({ userTwoFactorEnabled, sessionTwoFactorVerified });
|
||||
const isDeadlineExpired = isTwoFactorDeadlineExpired({ deadline, now });
|
||||
|
||||
return {
|
||||
required: true,
|
||||
deadline,
|
||||
isDeadlineExpired,
|
||||
isSatisfied,
|
||||
isBlocked: isDeadlineExpired && !isSatisfied,
|
||||
};
|
||||
};
|
||||
|
||||
export type OrganisationTwoFactorEnforcementSettings = {
|
||||
twoFactorRequired: boolean;
|
||||
twoFactorGracePeriodDays: number;
|
||||
twoFactorEnforcedFrom: Date | null;
|
||||
};
|
||||
|
||||
export type ComputeOrganisationTwoFactorEnforcementStatusOptions = {
|
||||
/**
|
||||
* The organisation's global settings 2FA fields.
|
||||
*/
|
||||
organisationSettings: OrganisationTwoFactorEnforcementSettings;
|
||||
|
||||
/**
|
||||
* When the user joined the organisation. Joining is never blocked — the
|
||||
* grace window starts at join.
|
||||
*/
|
||||
memberCreatedAt: Date;
|
||||
|
||||
userTwoFactorEnabled: boolean;
|
||||
|
||||
/**
|
||||
* Restarted by an admin 2FA reset, which restarts organisation grace too.
|
||||
*/
|
||||
userTwoFactorGraceStartedAt: Date;
|
||||
|
||||
/**
|
||||
* Whether the current session passed a second factor. Contexts without a
|
||||
* session (e.g. API access) never count as verified — machine access is
|
||||
* exempt from enforcement anyway, this only keeps the shape honest.
|
||||
*/
|
||||
sessionTwoFactorVerified: boolean;
|
||||
|
||||
now: Date;
|
||||
};
|
||||
|
||||
/**
|
||||
* Pure derivation of the per-organisation 2FA enforcement status for a
|
||||
* member + session.
|
||||
*
|
||||
* Deadline: `max(member.createdAt, orgSettings.twoFactorEnforcedFrom,
|
||||
* user.twoFactorGraceStartedAt) + orgSettings.twoFactorGracePeriodDays`.
|
||||
*/
|
||||
export const computeOrganisationTwoFactorEnforcementStatus = (
|
||||
options: ComputeOrganisationTwoFactorEnforcementStatusOptions,
|
||||
): TTwoFactorEnforcementStatus => {
|
||||
const {
|
||||
organisationSettings,
|
||||
memberCreatedAt,
|
||||
userTwoFactorEnabled,
|
||||
userTwoFactorGraceStartedAt,
|
||||
sessionTwoFactorVerified,
|
||||
now,
|
||||
} = options;
|
||||
|
||||
return computeTwoFactorEnforcementStatus({
|
||||
graceWindow: organisationSettings.twoFactorRequired
|
||||
? {
|
||||
anchors: [memberCreatedAt, organisationSettings.twoFactorEnforcedFrom, userTwoFactorGraceStartedAt],
|
||||
gracePeriodDays: organisationSettings.twoFactorGracePeriodDays,
|
||||
}
|
||||
: null,
|
||||
userTwoFactorEnabled,
|
||||
sessionTwoFactorVerified,
|
||||
now,
|
||||
});
|
||||
};
|
||||
|
||||
export type InstanceTwoFactorEnforcementStoredConfig = {
|
||||
enabled: boolean;
|
||||
gracePeriodDays: number;
|
||||
|
||||
/**
|
||||
* ISO datetime string, or null when enforcement was never enabled. Kept as
|
||||
* a string to match the stored `site.two-factor-enforcement` row shape.
|
||||
*/
|
||||
enforcedFrom: string | null;
|
||||
};
|
||||
|
||||
export type EvaluateInstanceTwoFactorEnforcementUpdateOptions = {
|
||||
/**
|
||||
* The currently stored configuration (schema defaults when the row is
|
||||
* absent or malformed).
|
||||
*/
|
||||
stored: InstanceTwoFactorEnforcementStoredConfig;
|
||||
|
||||
update: {
|
||||
enabled: boolean;
|
||||
gracePeriodDays: number;
|
||||
acknowledgeGracePeriodReduction?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether the instance license grants `instanceTwoFactorEnforcement`.
|
||||
*/
|
||||
isLicensed: boolean;
|
||||
|
||||
/**
|
||||
* The acting admin's own 2FA state, used for the enable-time guard.
|
||||
*/
|
||||
actor: IsTwoFactorSatisfiedOptions;
|
||||
|
||||
now: Date;
|
||||
};
|
||||
|
||||
export type InstanceTwoFactorEnforcementUpdateDecision =
|
||||
| {
|
||||
allowed: true;
|
||||
|
||||
/**
|
||||
* The configuration to persist. `enforcedFrom` is reset to `now` on an
|
||||
* off→on transition and preserved otherwise.
|
||||
*/
|
||||
next: InstanceTwoFactorEnforcementStoredConfig;
|
||||
}
|
||||
| {
|
||||
allowed: false;
|
||||
reason: 'UNLICENSED' | 'ENABLE_REQUIRES_ACTOR_TWO_FACTOR' | 'GRACE_REDUCTION_NOT_ACKNOWLEDGED';
|
||||
};
|
||||
|
||||
/**
|
||||
* Pure policy decision for updating the instance-wide 2FA enforcement
|
||||
* setting. The `admin.updateTwoFactorEnforcement` route delegates to this so
|
||||
* the entire decision is unit-testable without I/O.
|
||||
*
|
||||
* Rules, in evaluation order:
|
||||
*
|
||||
* 1. License gate: enabling — or changing any value while enabled — requires
|
||||
* the license flag. The ONLY unlicensed update allowed is disable-only: an
|
||||
* enabled→disabled transition with all other values unchanged from the
|
||||
* stored row (so an instance whose license lapsed can always turn the
|
||||
* policy off, but not tweak it).
|
||||
* 2. Enable-time guard: an off→on transition requires the acting admin to
|
||||
* already satisfy the policy being enabled (2FA enrolled AND second factor
|
||||
* verified on this session). Prevents self-lockout — a 0-day grace would
|
||||
* instantly block the actor from the very settings route that undoes it —
|
||||
* and removes the instant-DoS lever.
|
||||
* 3. Grace-reduction acknowledgement: shortening an active grace window
|
||||
* requires an explicit `acknowledgeGracePeriodReduction: true`.
|
||||
*/
|
||||
export const evaluateInstanceTwoFactorEnforcementUpdate = (
|
||||
options: EvaluateInstanceTwoFactorEnforcementUpdateOptions,
|
||||
): InstanceTwoFactorEnforcementUpdateDecision => {
|
||||
const { stored, update, isLicensed, actor, now } = options;
|
||||
|
||||
const isEnabling = update.enabled && !stored.enabled;
|
||||
|
||||
if (!isLicensed) {
|
||||
const isDisableOnly = stored.enabled && !update.enabled && update.gracePeriodDays === stored.gracePeriodDays;
|
||||
|
||||
if (!isDisableOnly) {
|
||||
return { allowed: false, reason: 'UNLICENSED' };
|
||||
}
|
||||
}
|
||||
|
||||
if (isEnabling && !isTwoFactorSatisfied(actor)) {
|
||||
return { allowed: false, reason: 'ENABLE_REQUIRES_ACTOR_TWO_FACTOR' };
|
||||
}
|
||||
|
||||
const nextEnforcedFrom = isEnabling ? now.toISOString() : stored.enforcedFrom;
|
||||
|
||||
const isGraceReduction = isTwoFactorGracePeriodReduction({
|
||||
previous: stored.enabled
|
||||
? {
|
||||
anchors: [stored.enforcedFrom ? new Date(stored.enforcedFrom) : null],
|
||||
gracePeriodDays: stored.gracePeriodDays,
|
||||
}
|
||||
: null,
|
||||
next: update.enabled
|
||||
? {
|
||||
anchors: [nextEnforcedFrom ? new Date(nextEnforcedFrom) : null],
|
||||
gracePeriodDays: update.gracePeriodDays,
|
||||
}
|
||||
: null,
|
||||
now,
|
||||
});
|
||||
|
||||
if (isGraceReduction && update.acknowledgeGracePeriodReduction !== true) {
|
||||
return { allowed: false, reason: 'GRACE_REDUCTION_NOT_ACKNOWLEDGED' };
|
||||
}
|
||||
|
||||
return {
|
||||
allowed: true,
|
||||
next: {
|
||||
enabled: update.enabled,
|
||||
gracePeriodDays: update.gracePeriodDays,
|
||||
enforcedFrom: nextEnforcedFrom,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export type IsInstanceTwoFactorEnforcementActiveOptions = {
|
||||
/**
|
||||
* The parsed `site.two-factor-enforcement` setting row, or null when the
|
||||
* row is missing or fails to parse.
|
||||
*/
|
||||
setting: { enabled: boolean } | null;
|
||||
|
||||
/**
|
||||
* Whether the instance license grants the `instanceTwoFactorEnforcement`
|
||||
* flag.
|
||||
*/
|
||||
isLicensed: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Activation decision for instance-wide 2FA enforcement.
|
||||
*
|
||||
* A manually inserted row on an unlicensed instance is a silent noop.
|
||||
*/
|
||||
export const isInstanceTwoFactorEnforcementActive = (options: IsInstanceTwoFactorEnforcementActiveOptions): boolean => {
|
||||
const { setting, isLicensed } = options;
|
||||
|
||||
return Boolean(setting?.enabled) && isLicensed;
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "OrganisationGlobalSettings" ADD COLUMN "twoFactorEnforcedFrom" TIMESTAMP(3),
|
||||
ADD COLUMN "twoFactorGracePeriodDays" INTEGER NOT NULL DEFAULT 7,
|
||||
ADD COLUMN "twoFactorRequired" BOOLEAN NOT NULL DEFAULT false;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Session" ADD COLUMN "authMethod" TEXT NOT NULL DEFAULT 'unknown',
|
||||
ADD COLUMN "twoFactorVerified" BOOLEAN NOT NULL DEFAULT false;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "User" ADD COLUMN "twoFactorGraceStartedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;
|
||||
|
||||
-- Backfill: existing users' grace windows anchor at account creation, not at
|
||||
-- migration time.
|
||||
UPDATE "User" SET "twoFactorGraceStartedAt" = "createdAt";
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "VerificationToken" ADD COLUMN "attempts" INTEGER NOT NULL DEFAULT 0;
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
-- AlterEnum
|
||||
ALTER TYPE "UserSecurityAuditLogType" ADD VALUE 'AUTH_2FA_ADMIN_RESET';
|
||||
@@ -63,6 +63,10 @@ model User {
|
||||
twoFactorEnabled Boolean @default(false)
|
||||
twoFactorBackupCodes String?
|
||||
|
||||
// Anchor for 2FA enforcement grace periods. Backfilled from `createdAt` for
|
||||
// existing users; reset by an admin 2FA reset to restart grace windows.
|
||||
twoFactorGraceStartedAt DateTime @default(now())
|
||||
|
||||
folders Folder[]
|
||||
envelopes Envelope[]
|
||||
|
||||
@@ -94,6 +98,7 @@ enum UserSecurityAuditLogType {
|
||||
ORGANISATION_SSO_UNLINK
|
||||
AUTH_2FA_DISABLE
|
||||
AUTH_2FA_ENABLE
|
||||
AUTH_2FA_ADMIN_RESET
|
||||
PASSKEY_CREATED
|
||||
PASSKEY_DELETED
|
||||
PASSKEY_UPDATED
|
||||
@@ -160,8 +165,14 @@ model VerificationToken {
|
||||
expires DateTime
|
||||
createdAt DateTime @default(now())
|
||||
metadata Json?
|
||||
userId Int
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
// Failed-attempt counter for tokens that accept guessable input (2FA
|
||||
// challenge codes). Atomically incremented; the token is consumed once it
|
||||
// reaches the cap.
|
||||
attempts Int @default(0)
|
||||
|
||||
userId Int
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
enum WebhookTriggerEvents {
|
||||
@@ -372,6 +383,15 @@ model Session {
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
// Plain string, not a PG enum. Validated by the zod enum in
|
||||
// packages/lib/types/session-auth-method.ts.
|
||||
authMethod String @default("unknown")
|
||||
|
||||
// Whether a second factor was passed at sign-in (TOTP/backup challenge or UV
|
||||
// passkey) or 2FA was enabled mid-session. Rows predating this column
|
||||
// backfill false — those sessions never qualify; users must re-login.
|
||||
twoFactorVerified Boolean @default(false)
|
||||
|
||||
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([userId])
|
||||
@@ -993,6 +1013,13 @@ model OrganisationGlobalSettings {
|
||||
|
||||
// AI features settings.
|
||||
aiFeaturesEnabled Boolean @default(false)
|
||||
|
||||
// 2FA enforcement settings. Org-level only — deliberately NOT mirrored in
|
||||
// TeamGlobalSettings. `extractDerivedTeamSettings` must skip these keys.
|
||||
twoFactorRequired Boolean @default(false)
|
||||
twoFactorGracePeriodDays Int @default(7)
|
||||
// Set server-side on each off→on transition of `twoFactorRequired`.
|
||||
twoFactorEnforcedFrom DateTime?
|
||||
}
|
||||
|
||||
/// @zod.import(["import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';", "import { ZDefaultRecipientsSchema } from '@documenso/lib/types/default-recipients';", "import { ZEnvelopeExpirationPeriod as ZEnvelopeExpirationPeriodSchema } from '@documenso/lib/constants/envelope-expiration';", "import { ZEnvelopeReminderSettings as ZEnvelopeReminderSettingsSchema } from '@documenso/lib/constants/envelope-reminder';", "import { ZCssVarsSchema } from '@documenso/lib/types/css-vars';"])
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
"types": "./index.ts",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"clean": "rimraf node_modules"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { getInstanceTwoFactorEnforcementConfig } from '@documenso/lib/server-only/2fa/get-instance-two-factor-enforcement-config';
|
||||
|
||||
import { adminProcedure } from '../trpc';
|
||||
import {
|
||||
ZGetTwoFactorEnforcementRequestSchema,
|
||||
ZGetTwoFactorEnforcementResponseSchema,
|
||||
} from './get-two-factor-enforcement.types';
|
||||
|
||||
/**
|
||||
* Returns the raw stored instance 2FA enforcement configuration together with
|
||||
* the license/activation state for the admin settings UI ("configured but
|
||||
* inactive" needs the stored values the enforcement policy getter hides).
|
||||
*
|
||||
* 2FA enforcement metadata: inherits `'none'` from the admin base — admin
|
||||
* procedures carry no organisation scope, while the INSTANCE assert still
|
||||
* applies via `adminMiddleware`.
|
||||
*/
|
||||
export const getTwoFactorEnforcementRoute = adminProcedure
|
||||
.input(ZGetTwoFactorEnforcementRequestSchema)
|
||||
.output(ZGetTwoFactorEnforcementResponseSchema)
|
||||
.query(async () => {
|
||||
return await getInstanceTwoFactorEnforcementConfig();
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const ZGetTwoFactorEnforcementRequestSchema = z.void();
|
||||
|
||||
export const ZGetTwoFactorEnforcementResponseSchema = z.object({
|
||||
enabled: z.boolean(),
|
||||
gracePeriodDays: z.number().int().min(0).max(365),
|
||||
enforcedFrom: z.string().datetime().nullable(),
|
||||
isLicensed: z.boolean(),
|
||||
isActive: z.boolean(),
|
||||
});
|
||||
|
||||
export type TGetTwoFactorEnforcementRequest = z.infer<typeof ZGetTwoFactorEnforcementRequestSchema>;
|
||||
export type TGetTwoFactorEnforcementResponse = z.infer<typeof ZGetTwoFactorEnforcementResponseSchema>;
|
||||
@@ -1,5 +1,7 @@
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { UserSecurityAuditLogType } from '@prisma/client';
|
||||
|
||||
import { adminProcedure } from '../trpc';
|
||||
import { ZResetTwoFactorRequestSchema, ZResetTwoFactorResponseSchema } from './reset-two-factor-authentication.types';
|
||||
@@ -16,14 +18,45 @@ export const resetTwoFactorRoute = adminProcedure
|
||||
},
|
||||
});
|
||||
|
||||
return await resetTwoFactor({ userId });
|
||||
return await resetTwoFactor({
|
||||
userId,
|
||||
actorUserId: ctx.user.id,
|
||||
requestMetadata: ctx.metadata.requestMetadata,
|
||||
});
|
||||
});
|
||||
|
||||
export type ResetTwoFactorOptions = {
|
||||
userId: number;
|
||||
|
||||
/**
|
||||
* The acting admin. Self-reset is forbidden — an admin who loses their
|
||||
* authenticator goes through the normal recovery paths instead of quietly
|
||||
* restarting their own grace window.
|
||||
*/
|
||||
actorUserId: number;
|
||||
|
||||
requestMetadata?: RequestMetadata;
|
||||
};
|
||||
|
||||
export const resetTwoFactor = async ({ userId }: ResetTwoFactorOptions) => {
|
||||
/**
|
||||
* Admin reset of a user's 2FA configuration.
|
||||
*
|
||||
* Also restarts the user's grace window (`twoFactorGraceStartedAt = now`) —
|
||||
* for BOTH instance and organisation enforcement, since the org deadline
|
||||
* anchors on this field too. Without the restart, a reset under active
|
||||
* enforcement would instantly block the user.
|
||||
*
|
||||
* Because a reset silently extends org grace across a trust boundary
|
||||
* (instance admin → org policy), it is made traceable via a
|
||||
* `AUTH_2FA_ADMIN_RESET` security audit log entry.
|
||||
*/
|
||||
export const resetTwoFactor = async ({ userId, actorUserId, requestMetadata }: ResetTwoFactorOptions) => {
|
||||
if (userId === actorUserId) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'You cannot reset your own two factor authentication',
|
||||
});
|
||||
}
|
||||
|
||||
const user = await prisma.user.findFirst({
|
||||
where: {
|
||||
id: userId,
|
||||
@@ -34,14 +67,28 @@ export const resetTwoFactor = async ({ userId }: ResetTwoFactorOptions) => {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, { message: 'User not found' });
|
||||
}
|
||||
|
||||
await prisma.user.update({
|
||||
where: {
|
||||
id: user.id,
|
||||
},
|
||||
data: {
|
||||
twoFactorEnabled: false,
|
||||
twoFactorBackupCodes: null,
|
||||
twoFactorSecret: null,
|
||||
},
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.user.update({
|
||||
where: {
|
||||
id: user.id,
|
||||
},
|
||||
data: {
|
||||
twoFactorEnabled: false,
|
||||
twoFactorBackupCodes: null,
|
||||
twoFactorSecret: null,
|
||||
// Restart the grace window: anchors both the instance deadline and,
|
||||
// through the org deadline's max-of-anchors, every org deadline.
|
||||
twoFactorGraceStartedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
await tx.userSecurityAuditLog.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
type: UserSecurityAuditLogType.AUTH_2FA_ADMIN_RESET,
|
||||
userAgent: requestMetadata?.userAgent,
|
||||
ipAddress: requestMetadata?.ipAddress,
|
||||
},
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
@@ -30,6 +30,7 @@ import { findUserTeamsRoute } from './find-user-teams';
|
||||
import { getAdminOrganisationRoute } from './get-admin-organisation';
|
||||
import { getAdminTeamRoute } from './get-admin-team';
|
||||
import { getEmailDomainRoute } from './get-email-domain';
|
||||
import { getTwoFactorEnforcementRoute } from './get-two-factor-enforcement';
|
||||
import { getUserRoute } from './get-user';
|
||||
import { promoteMemberToOwnerRoute } from './promote-member-to-owner';
|
||||
import { reregisterEmailDomainRoute } from './reregister-email-domain';
|
||||
@@ -44,6 +45,7 @@ import { updateOrganisationMemberRoleRoute } from './update-organisation-member-
|
||||
import { updateRecipientRoute } from './update-recipient';
|
||||
import { updateSiteSettingRoute } from './update-site-setting';
|
||||
import { updateSubscriptionClaimRoute } from './update-subscription-claim';
|
||||
import { updateTwoFactorEnforcementRoute } from './update-two-factor-enforcement';
|
||||
import { updateUserRoute } from './update-user';
|
||||
|
||||
export const adminRouter = router({
|
||||
@@ -121,4 +123,6 @@ export const adminRouter = router({
|
||||
},
|
||||
search: adminSearchRoute,
|
||||
updateSiteSetting: updateSiteSettingRoute,
|
||||
getTwoFactorEnforcement: getTwoFactorEnforcementRoute,
|
||||
updateTwoFactorEnforcement: updateTwoFactorEnforcementRoute,
|
||||
});
|
||||
|
||||
@@ -1,7 +1,20 @@
|
||||
import { ZSiteSettingSchema } from '@documenso/lib/server-only/site-settings/schema';
|
||||
import { ZSiteSettingsBannerSchema } from '@documenso/lib/server-only/site-settings/schemas/banner';
|
||||
import { ZSiteSettingsEmailBlocklistSchema } from '@documenso/lib/server-only/site-settings/schemas/email-blocklist';
|
||||
import { ZSiteSettingsTelemetrySchema } from '@documenso/lib/server-only/site-settings/schemas/telemetry';
|
||||
import { z } from 'zod';
|
||||
|
||||
export const ZUpdateSiteSettingRequestSchema = ZSiteSettingSchema;
|
||||
/**
|
||||
* Write union for the generic site-setting update route. Deliberately
|
||||
* EXCLUDES `ZSiteSettingsTwoFactorEnforcementSchema` (which remains in the
|
||||
* read union): the 2FA enforcement setting is only writable via its dedicated
|
||||
* admin procedure, which carries the license assert, the enable-time guard
|
||||
* and the grace-reduction acknowledgement.
|
||||
*/
|
||||
export const ZUpdateSiteSettingRequestSchema = z.union([
|
||||
ZSiteSettingsBannerSchema,
|
||||
ZSiteSettingsEmailBlocklistSchema,
|
||||
ZSiteSettingsTelemetrySchema,
|
||||
]);
|
||||
|
||||
export const ZUpdateSiteSettingResponseSchema = z.void();
|
||||
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { getInstanceTwoFactorEnforcementConfig } from '@documenso/lib/server-only/2fa/get-instance-two-factor-enforcement-config';
|
||||
import { SITE_SETTINGS_TWO_FACTOR_ENFORCEMENT_ID } from '@documenso/lib/server-only/site-settings/schemas/two-factor-enforcement';
|
||||
import { upsertSiteSetting } from '@documenso/lib/server-only/site-settings/upsert-site-setting';
|
||||
import { evaluateInstanceTwoFactorEnforcementUpdate } from '@documenso/lib/utils/two-factor';
|
||||
import type { Session } from '@prisma/client';
|
||||
|
||||
import { adminProcedure } from '../trpc';
|
||||
import {
|
||||
ZUpdateTwoFactorEnforcementRequestSchema,
|
||||
ZUpdateTwoFactorEnforcementResponseSchema,
|
||||
} from './update-two-factor-enforcement.types';
|
||||
|
||||
/**
|
||||
* Dedicated write route for the `site.two-factor-enforcement` setting. The
|
||||
* generic `admin.updateSiteSetting` deliberately excludes this setting from
|
||||
* its write union — this route carries the license assert, the enable-time
|
||||
* guard and the grace-reduction acknowledgement.
|
||||
*
|
||||
* The full policy decision (license gate incl. the unlicensed disable-only
|
||||
* exception, enable-time guard, grace-reduction acknowledgement, server-side
|
||||
* `enforcedFrom` reset on off→on) lives in the pure
|
||||
* `evaluateInstanceTwoFactorEnforcementUpdate` helper so it is unit-testable.
|
||||
*
|
||||
* 2FA enforcement metadata: inherits `'none'` from the admin base — admin
|
||||
* procedures carry no organisation scope, while the INSTANCE assert still
|
||||
* applies via `adminMiddleware`.
|
||||
*/
|
||||
export const updateTwoFactorEnforcementRoute = adminProcedure
|
||||
.input(ZUpdateTwoFactorEnforcementRequestSchema)
|
||||
.output(ZUpdateTwoFactorEnforcementResponseSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { enabled, gracePeriodDays, acknowledgeGracePeriodReduction } = input;
|
||||
|
||||
// Explicitly asserted local, mirroring `update-organisation-settings`:
|
||||
// avoids control-flow narrowing differences between workspace tsconfigs.
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
const requestSession = ctx.session as Pick<Session, 'twoFactorVerified'> | null;
|
||||
|
||||
ctx.logger.info({
|
||||
input: {
|
||||
enabled,
|
||||
gracePeriodDays,
|
||||
},
|
||||
});
|
||||
|
||||
const storedConfig = await getInstanceTwoFactorEnforcementConfig();
|
||||
|
||||
const decision = evaluateInstanceTwoFactorEnforcementUpdate({
|
||||
stored: {
|
||||
enabled: storedConfig.enabled,
|
||||
gracePeriodDays: storedConfig.gracePeriodDays,
|
||||
enforcedFrom: storedConfig.enforcedFrom,
|
||||
},
|
||||
update: {
|
||||
enabled,
|
||||
gracePeriodDays,
|
||||
acknowledgeGracePeriodReduction,
|
||||
},
|
||||
isLicensed: storedConfig.isLicensed,
|
||||
actor: {
|
||||
userTwoFactorEnabled: ctx.user.twoFactorEnabled,
|
||||
sessionTwoFactorVerified: requestSession?.twoFactorVerified ?? false,
|
||||
},
|
||||
now: new Date(),
|
||||
});
|
||||
|
||||
if (!decision.allowed) {
|
||||
switch (decision.reason) {
|
||||
case 'UNLICENSED':
|
||||
throw new AppError(AppErrorCode.FORBIDDEN, {
|
||||
message:
|
||||
'Your license does not include instance-wide two-factor enforcement. Without the license the only permitted change is disabling the currently stored configuration.',
|
||||
statusCode: 403,
|
||||
});
|
||||
|
||||
case 'ENABLE_REQUIRES_ACTOR_TWO_FACTOR':
|
||||
throw new AppError(AppErrorCode.TWO_FACTOR_REQUIRED, {
|
||||
message:
|
||||
'You must have two-factor authentication enabled and verified on this session before requiring it for the instance.',
|
||||
statusCode: 403,
|
||||
});
|
||||
|
||||
case 'GRACE_REDUCTION_NOT_ACKNOWLEDGED':
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message:
|
||||
'This change reduces the active two-factor authentication grace period and must be explicitly acknowledged.',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await upsertSiteSetting({
|
||||
id: SITE_SETTINGS_TWO_FACTOR_ENFORCEMENT_ID,
|
||||
enabled: decision.next.enabled,
|
||||
data: {
|
||||
gracePeriodDays: decision.next.gracePeriodDays,
|
||||
enforcedFrom: decision.next.enforcedFrom,
|
||||
},
|
||||
userId: ctx.user.id,
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const ZUpdateTwoFactorEnforcementRequestSchema = z.object({
|
||||
enabled: z.boolean(),
|
||||
gracePeriodDays: z.number().int().min(0).max(365),
|
||||
|
||||
/**
|
||||
* Required (as `true`) when the update reduces an active grace window.
|
||||
*/
|
||||
acknowledgeGracePeriodReduction: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export const ZUpdateTwoFactorEnforcementResponseSchema = z.void();
|
||||
|
||||
export type TUpdateTwoFactorEnforcementRequest = z.infer<typeof ZUpdateTwoFactorEnforcementRequestSchema>;
|
||||
export type TUpdateTwoFactorEnforcementResponse = z.infer<typeof ZUpdateTwoFactorEnforcementResponseSchema>;
|
||||
@@ -4,11 +4,13 @@ import { fireAndForget } from '@documenso/lib/universal/fire-and-forget';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { authenticatedProcedure } from '../trpc';
|
||||
import { twoFactorScope } from '../two-factor-enforcement/enforce';
|
||||
import { ZCreateApiTokenRequestSchema, ZCreateApiTokenResponseSchema } from './create-api-token.types';
|
||||
|
||||
export const createApiTokenRoute = authenticatedProcedure
|
||||
.input(ZCreateApiTokenRequestSchema)
|
||||
.output(ZCreateApiTokenResponseSchema)
|
||||
.use(twoFactorScope((input) => ({ team: input.teamId })))
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const { tokenName, teamId, expirationDate } = input;
|
||||
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { deleteTokenById } from '@documenso/lib/server-only/public-api/delete-api-token-by-id';
|
||||
|
||||
import { authenticatedProcedure } from '../trpc';
|
||||
import { twoFactorScope } from '../two-factor-enforcement/enforce';
|
||||
import { ZDeleteApiTokenRequestSchema, ZDeleteApiTokenResponseSchema } from './delete-api-token.types';
|
||||
|
||||
export const deleteApiTokenRoute = authenticatedProcedure
|
||||
.input(ZDeleteApiTokenRequestSchema)
|
||||
.output(ZDeleteApiTokenResponseSchema)
|
||||
.use(twoFactorScope((input) => ({ team: input.teamId })))
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const { id, teamId } = input;
|
||||
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { getApiTokens } from '@documenso/lib/server-only/public-api/get-api-tokens';
|
||||
|
||||
import { authenticatedProcedure } from '../trpc';
|
||||
import { twoFactorScopeFromCtx } from '../two-factor-enforcement/enforce';
|
||||
import { ZGetApiTokensRequestSchema, ZGetApiTokensResponseSchema } from './get-api-tokens.types';
|
||||
|
||||
export const getApiTokensRoute = authenticatedProcedure
|
||||
.input(ZGetApiTokensRequestSchema)
|
||||
.output(ZGetApiTokensResponseSchema)
|
||||
.use(twoFactorScopeFromCtx())
|
||||
.query(async ({ ctx }) => {
|
||||
const { teamId } = ctx;
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createPasskeyAuthenticationOptions } from '@documenso/lib/server-only/auth/create-passkey-authentication-options';
|
||||
|
||||
import { authenticatedProcedure } from '../trpc';
|
||||
import { twoFactorInstanceOnly } from '../two-factor-enforcement/enforce';
|
||||
import {
|
||||
ZCreatePasskeyAuthenticationOptionsRequestSchema,
|
||||
ZCreatePasskeyAuthenticationOptionsResponseSchema,
|
||||
@@ -9,6 +10,8 @@ import {
|
||||
export const createPasskeyAuthenticationOptionsRoute = authenticatedProcedure
|
||||
.input(ZCreatePasskeyAuthenticationOptionsRequestSchema)
|
||||
.output(ZCreatePasskeyAuthenticationOptionsResponseSchema)
|
||||
// 2FA enforcement: user-level passkey credential management; no organisation scope. Instance assert still applies.
|
||||
.use(twoFactorInstanceOnly())
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
return await createPasskeyAuthenticationOptions({
|
||||
userId: ctx.user.id,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createPasskeyRegistrationOptions } from '@documenso/lib/server-only/auth/create-passkey-registration-options';
|
||||
|
||||
import { authenticatedProcedure } from '../trpc';
|
||||
import { twoFactorInstanceOnly } from '../two-factor-enforcement/enforce';
|
||||
import {
|
||||
ZCreatePasskeyRegistrationOptionsRequestSchema,
|
||||
ZCreatePasskeyRegistrationOptionsResponseSchema,
|
||||
@@ -9,6 +10,8 @@ import {
|
||||
export const createPasskeyRegistrationOptionsRoute = authenticatedProcedure
|
||||
.input(ZCreatePasskeyRegistrationOptionsRequestSchema)
|
||||
.output(ZCreatePasskeyRegistrationOptionsResponseSchema)
|
||||
// 2FA enforcement: user-level passkey credential management; no organisation scope. Instance assert still applies.
|
||||
.use(twoFactorInstanceOnly())
|
||||
.mutation(async ({ ctx }) => {
|
||||
return await createPasskeyRegistrationOptions({
|
||||
userId: ctx.user.id,
|
||||
|
||||
@@ -2,11 +2,14 @@ import { createPasskey } from '@documenso/lib/server-only/auth/create-passkey';
|
||||
import type { RegistrationResponseJSON } from '@simplewebauthn/server';
|
||||
|
||||
import { authenticatedProcedure } from '../trpc';
|
||||
import { twoFactorInstanceOnly } from '../two-factor-enforcement/enforce';
|
||||
import { ZCreatePasskeyRequestSchema, ZCreatePasskeyResponseSchema } from './create-passkey.types';
|
||||
|
||||
export const createPasskeyRoute = authenticatedProcedure
|
||||
.input(ZCreatePasskeyRequestSchema)
|
||||
.output(ZCreatePasskeyResponseSchema)
|
||||
// 2FA enforcement: user-level passkey credential management; no organisation scope. Instance assert still applies.
|
||||
.use(twoFactorInstanceOnly())
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
const verificationResponse = input.verificationResponse as RegistrationResponseJSON;
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import { deletePasskey } from '@documenso/lib/server-only/auth/delete-passkey';
|
||||
|
||||
import { authenticatedProcedure } from '../trpc';
|
||||
import { twoFactorInstanceOnly } from '../two-factor-enforcement/enforce';
|
||||
import { ZDeletePasskeyRequestSchema, ZDeletePasskeyResponseSchema } from './delete-passkey.types';
|
||||
|
||||
export const deletePasskeyRoute = authenticatedProcedure
|
||||
.input(ZDeletePasskeyRequestSchema)
|
||||
.output(ZDeletePasskeyResponseSchema)
|
||||
// 2FA enforcement: user-level passkey credential management; no organisation scope. Instance assert still applies.
|
||||
.use(twoFactorInstanceOnly())
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { passkeyId } = input;
|
||||
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import { findPasskeys } from '@documenso/lib/server-only/auth/find-passkeys';
|
||||
|
||||
import { authenticatedProcedure } from '../trpc';
|
||||
import { twoFactorInstanceOnly } from '../two-factor-enforcement/enforce';
|
||||
import { ZFindPasskeysRequestSchema, ZFindPasskeysResponseSchema } from './find-passkeys.types';
|
||||
|
||||
export const findPasskeysRoute = authenticatedProcedure
|
||||
.input(ZFindPasskeysRequestSchema)
|
||||
.output(ZFindPasskeysResponseSchema)
|
||||
// 2FA enforcement: user-level passkey credential management; no organisation scope. Instance assert still applies.
|
||||
.use(twoFactorInstanceOnly())
|
||||
.query(async ({ input, ctx }) => {
|
||||
const { page, perPage, orderBy } = input;
|
||||
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import { updatePasskey } from '@documenso/lib/server-only/auth/update-passkey';
|
||||
|
||||
import { authenticatedProcedure } from '../trpc';
|
||||
import { twoFactorInstanceOnly } from '../two-factor-enforcement/enforce';
|
||||
import { ZUpdatePasskeyRequestSchema, ZUpdatePasskeyResponseSchema } from './update-passkey.types';
|
||||
|
||||
export const updatePasskeyRoute = authenticatedProcedure
|
||||
.input(ZUpdatePasskeyRequestSchema)
|
||||
.output(ZUpdatePasskeyResponseSchema)
|
||||
// 2FA enforcement: user-level passkey credential management; no organisation scope. Instance assert still applies.
|
||||
.use(twoFactorInstanceOnly())
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { passkeyId, name } = input;
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { createAttachment } from '@documenso/lib/server-only/envelope-attachment
|
||||
import { EnvelopeType } from '@prisma/client';
|
||||
|
||||
import { authenticatedProcedure } from '../../trpc';
|
||||
import { twoFactorScope } from '../../two-factor-enforcement/enforce';
|
||||
import { ZCreateAttachmentRequestSchema, ZCreateAttachmentResponseSchema } from './create-attachment.types';
|
||||
|
||||
export const createAttachmentRoute = authenticatedProcedure
|
||||
@@ -20,6 +21,7 @@ export const createAttachmentRoute = authenticatedProcedure
|
||||
})
|
||||
.input(ZCreateAttachmentRequestSchema)
|
||||
.output(ZCreateAttachmentResponseSchema)
|
||||
.use(twoFactorScope((input) => ({ document: input.documentId })))
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const { teamId } = ctx;
|
||||
const userId = ctx.user.id;
|
||||
|
||||
@@ -2,6 +2,7 @@ import { deleteAttachment } from '@documenso/lib/server-only/envelope-attachment
|
||||
|
||||
import { ZGenericSuccessResponse } from '../../schema';
|
||||
import { authenticatedProcedure } from '../../trpc';
|
||||
import { twoFactorScope } from '../../two-factor-enforcement/enforce';
|
||||
import { ZDeleteAttachmentRequestSchema, ZDeleteAttachmentResponseSchema } from './delete-attachment.types';
|
||||
|
||||
export const deleteAttachmentRoute = authenticatedProcedure
|
||||
@@ -18,6 +19,7 @@ export const deleteAttachmentRoute = authenticatedProcedure
|
||||
})
|
||||
.input(ZDeleteAttachmentRequestSchema)
|
||||
.output(ZDeleteAttachmentResponseSchema)
|
||||
.use(twoFactorScope((input) => ({ attachment: input.id })))
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const { teamId } = ctx;
|
||||
const userId = ctx.user.id;
|
||||
|
||||
@@ -4,6 +4,7 @@ import { findAttachmentsByEnvelopeId } from '@documenso/lib/server-only/envelope
|
||||
import { EnvelopeType } from '@prisma/client';
|
||||
|
||||
import { authenticatedProcedure } from '../../trpc';
|
||||
import { twoFactorScope } from '../../two-factor-enforcement/enforce';
|
||||
import { ZFindAttachmentsRequestSchema, ZFindAttachmentsResponseSchema } from './find-attachments.types';
|
||||
|
||||
export const findAttachmentsRoute = authenticatedProcedure
|
||||
@@ -20,6 +21,7 @@ export const findAttachmentsRoute = authenticatedProcedure
|
||||
})
|
||||
.input(ZFindAttachmentsRequestSchema)
|
||||
.output(ZFindAttachmentsResponseSchema)
|
||||
.use(twoFactorScope((input) => ({ document: input.documentId })))
|
||||
.query(async ({ input, ctx }) => {
|
||||
const { documentId } = input;
|
||||
const { teamId } = ctx;
|
||||
|
||||
@@ -2,6 +2,7 @@ import { updateAttachment } from '@documenso/lib/server-only/envelope-attachment
|
||||
|
||||
import { ZGenericSuccessResponse } from '../../schema';
|
||||
import { authenticatedProcedure } from '../../trpc';
|
||||
import { twoFactorScope } from '../../two-factor-enforcement/enforce';
|
||||
import { ZUpdateAttachmentRequestSchema, ZUpdateAttachmentResponseSchema } from './update-attachment.types';
|
||||
|
||||
export const updateAttachmentRoute = authenticatedProcedure
|
||||
@@ -18,6 +19,7 @@ export const updateAttachmentRoute = authenticatedProcedure
|
||||
})
|
||||
.input(ZUpdateAttachmentRequestSchema)
|
||||
.output(ZUpdateAttachmentResponseSchema)
|
||||
.use(twoFactorScope((input) => ({ attachment: input.id })))
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const { teamId } = ctx;
|
||||
const userId = ctx.user.id;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user