mirror of
https://github.com/documenso/documenso.git
synced 2026-09-30 16:54:34 +10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a39e034876 | ||
|
|
5ab94b86fd | ||
|
|
2140d132d7 | ||
|
|
d91a3e3828 |
@@ -30,26 +30,6 @@ 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
|
||||
|
||||
@@ -186,6 +186,15 @@ Controls envelope configuration options. Set to `null` to hide envelope settings
|
||||
| `allowConfigureEmailSender` | `boolean` | `true` | Allow configuring the email sender |
|
||||
| `allowConfigureEmailReplyTo` | `boolean` | `true` | Allow configuring the email reply-to |
|
||||
|
||||
<Callout title="Allow Document Rejection">
|
||||
The settings dialog also contains an "Allow Document Rejection" option that is always shown. It sets the document's
|
||||
own rejection setting (`meta.allowDocumentRejection` in the API) and defaults to the organisation or team document
|
||||
preference. This
|
||||
is separate from the `allowDocumentRejection` option of the embedded signing views: that option only controls the
|
||||
signing embed's UI, while this one is stored on the document and enforced by the server. A recipient can reject only
|
||||
when both allow it.
|
||||
</Callout>
|
||||
|
||||
### Actions
|
||||
|
||||
Controls available actions during editing:
|
||||
|
||||
@@ -47,10 +47,20 @@ Documenso expects the fragment to be **base64** of:
|
||||
| `lockEmail` | `boolean` | Lock the email field (prevents editing). |
|
||||
| `language` | `string` | Force the embed language (e.g. `en`). |
|
||||
| `darkModeDisabled` | `boolean` | Disable dark mode behavior. |
|
||||
| `allowDocumentRejection` | `boolean` | Allow or disallow document rejection. |
|
||||
| `allowDocumentRejection` | `boolean` | Show the reject option in the embed. Defaults to `false`. The document must also allow rejection, see below. |
|
||||
| `css` | `string` | Inject custom CSS into the embed. |
|
||||
| `cssVars` | `object` | Override embed CSS variables (see the CSS Variables page). |
|
||||
|
||||
<Callout title="allowDocumentRejection and the document's own setting">
|
||||
Two settings control whether a recipient can reject a document in an embed, and both must allow it:
|
||||
|
||||
- `allowDocumentRejection` above, which you pass per embed. It only controls your embed's UI and defaults to `false`.
|
||||
- The document's own "Allow Document Rejection" setting. Its default comes from the organisation or team document
|
||||
preferences, and it can be changed per document in the editor or through the API (`meta.allowDocumentRejection`).
|
||||
When it is disabled, the reject option stays hidden even if you pass `allowDocumentRejection: true`, and the server
|
||||
refuses rejection requests for that document.
|
||||
</Callout>
|
||||
|
||||
#### Example
|
||||
|
||||
```ts
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
"background-jobs",
|
||||
"signing-certificate",
|
||||
"telemetry",
|
||||
"two-factor-enforcement",
|
||||
"organisation-limits",
|
||||
"advanced"
|
||||
]
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
---
|
||||
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
|
||||
@@ -15,7 +15,7 @@
|
||||
"fumadocs-ui": "16.14.3",
|
||||
"lucide-react": "^0.563.0",
|
||||
"mermaid": "^11.12.2",
|
||||
"next": "^16.3.3",
|
||||
"next": "16.3.0",
|
||||
"next-plausible": "^3.12.5",
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "^19.2.4",
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
"dependencies": {
|
||||
"@documenso/prisma": "*",
|
||||
"luxon": "^3.7.2",
|
||||
"next": "^16.3.3"
|
||||
"next": "16.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20",
|
||||
|
||||
@@ -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 authentication for this user.`,
|
||||
() => msg`You are not authorized to reset two factor authentcation for this user.`,
|
||||
)
|
||||
.otherwise(() => msg`An error occurred while resetting two factor authentication for the user.`);
|
||||
|
||||
@@ -85,8 +85,7 @@ 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. Their two-factor enforcement grace period will restart from the moment of the
|
||||
reset.
|
||||
authentication for the user.
|
||||
</Trans>
|
||||
</AlertDescription>
|
||||
</div>
|
||||
@@ -109,8 +108,7 @@ 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. Any
|
||||
two-factor enforcement grace period for this user will restart from the moment of the reset.
|
||||
This action is irreversible. Please ensure you have informed the user before proceeding.
|
||||
</Trans>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
@@ -96,6 +96,9 @@ export const DocumentPreferencesResetDialog = ({
|
||||
<li>
|
||||
<Trans>Delegate document ownership</Trans>
|
||||
</li>
|
||||
<li>
|
||||
<Trans>Allow document rejection</Trans>
|
||||
</li>
|
||||
{showAiFeatures && (
|
||||
<li>
|
||||
<Trans>AI features</Trans>
|
||||
|
||||
@@ -96,6 +96,9 @@ export const EmbedSignDocumentV1ClientPage = ({
|
||||
|
||||
const [allowDocumentRejection, setAllowDocumentRejection] = useState(false);
|
||||
|
||||
// Both the embed host and the document settings must allow rejection.
|
||||
const isDocumentRejectionAllowed = allowDocumentRejection && (metadata?.allowDocumentRejection ?? true);
|
||||
|
||||
const selectedSigner = allRecipients.find((r) => r.id === selectedSignerId);
|
||||
const isAssistantMode = recipient.role === RecipientRole.ASSISTANT;
|
||||
|
||||
@@ -303,7 +306,7 @@ export const EmbedSignDocumentV1ClientPage = ({
|
||||
<div className="embed--Actions mb-4 flex w-full flex-row-reverse items-baseline justify-between">
|
||||
<DocumentSigningAttachmentsPopover envelopeId={envelopeId} token={token} />
|
||||
|
||||
{allowDocumentRejection && (
|
||||
{isDocumentRejectionAllowed && (
|
||||
<DocumentSigningRejectDialog documentId={documentId} token={token} onRejected={onDocumentRejected} />
|
||||
)}
|
||||
</div>
|
||||
@@ -502,7 +505,7 @@ export const EmbedSignDocumentV1ClientPage = ({
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
className={allowDocumentRejection ? 'col-start-2' : 'col-span-2'}
|
||||
className={isDocumentRejectionAllowed ? 'col-start-2' : 'col-span-2'}
|
||||
disabled={isThrottled}
|
||||
loading={isSubmitting}
|
||||
onClick={() => throttledOnCompleteClick()}
|
||||
|
||||
@@ -228,7 +228,7 @@ export const MultiSignDocumentSigningView = ({
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{allowDocumentRejection && (
|
||||
{allowDocumentRejection && (document.documentMeta?.allowDocumentRejection ?? true) && (
|
||||
<div className="embed--Actions mt-8 mb-4 flex w-full flex-row-reverse items-baseline justify-between">
|
||||
<DocumentSigningRejectDialog documentId={document.id} token={token} onRejected={onRejected} />
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
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,
|
||||
@@ -69,23 +68,15 @@ 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 {
|
||||
const { orgEnforcementApplies } = await authClient.twoFactor.disable({ totpCode, backupCode });
|
||||
await authClient.twoFactor.disable({ totpCode, backupCode });
|
||||
|
||||
toast({
|
||||
title: _(msg`Two-factor authentication disabled`),
|
||||
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.`,
|
||||
),
|
||||
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.`,
|
||||
),
|
||||
});
|
||||
|
||||
flushSync(() => {
|
||||
@@ -93,19 +84,7 @@ export const DisableAuthenticatorAppDialog = () => {
|
||||
});
|
||||
|
||||
await refreshSession();
|
||||
} 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;
|
||||
}
|
||||
|
||||
} catch (_err) {
|
||||
toast({
|
||||
title: _(msg`Unable to disable two-factor authentication`),
|
||||
description: _(
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
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,
|
||||
@@ -70,18 +69,11 @@ export const EnableAuthenticatorAppDialog = ({ onSuccess }: EnableAuthenticatorA
|
||||
|
||||
setSetup2FAData(data);
|
||||
} catch (err) {
|
||||
const error = AppError.parseError(err);
|
||||
|
||||
toast({
|
||||
title: _(msg`Unable to setup two-factor authentication`),
|
||||
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.`,
|
||||
),
|
||||
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',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@ export type TDocumentPreferencesFormSchema = {
|
||||
signatureTypes: DocumentSignatureType[];
|
||||
defaultRecipients: TDefaultRecipients | null;
|
||||
delegateDocumentOwnership: boolean | null;
|
||||
allowDocumentRejection: boolean | null;
|
||||
aiFeaturesEnabled: boolean | null;
|
||||
};
|
||||
|
||||
@@ -57,6 +58,7 @@ type SettingsSubset = Pick<
|
||||
| 'drawSignatureEnabled'
|
||||
| 'defaultRecipients'
|
||||
| 'delegateDocumentOwnership'
|
||||
| 'allowDocumentRejection'
|
||||
| 'aiFeaturesEnabled'
|
||||
>;
|
||||
|
||||
@@ -77,6 +79,7 @@ const getDocumentPreferencesFormValues = (settings: SettingsSubset): TDocumentPr
|
||||
signatureTypes: extractTeamSignatureSettings({ ...settings }),
|
||||
defaultRecipients: settings.defaultRecipients ? ZDefaultRecipientsSchema.parse(settings.defaultRecipients) : null,
|
||||
delegateDocumentOwnership: settings.delegateDocumentOwnership,
|
||||
allowDocumentRejection: settings.allowDocumentRejection,
|
||||
aiFeaturesEnabled: settings.aiFeaturesEnabled,
|
||||
};
|
||||
};
|
||||
@@ -101,6 +104,7 @@ export const DocumentPreferencesForm = ({ settings, onFormSubmit, canInherit }:
|
||||
}),
|
||||
defaultRecipients: ZDefaultRecipientsSchema.nullable(),
|
||||
delegateDocumentOwnership: z.boolean().nullable(),
|
||||
allowDocumentRejection: z.boolean().nullable(),
|
||||
aiFeaturesEnabled: z.boolean().nullable(),
|
||||
});
|
||||
|
||||
@@ -486,6 +490,60 @@ export const DocumentPreferencesForm = ({ settings, onFormSubmit, canInherit }:
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="allowDocumentRejection"
|
||||
render={({ field }) => (
|
||||
<InheritableField
|
||||
className="flex-1"
|
||||
canInherit={canInherit}
|
||||
isInherited={field.value === null}
|
||||
label={<Trans>Allow Document Rejection</Trans>}
|
||||
testId="allow-document-rejection"
|
||||
>
|
||||
<FormControl>
|
||||
<Select
|
||||
{...field}
|
||||
value={field.value === null ? '-1' : field.value.toString()}
|
||||
onValueChange={(value) =>
|
||||
field.onChange(value === 'true' ? true : value === 'false' ? false : null)
|
||||
}
|
||||
>
|
||||
<SelectTrigger
|
||||
className="bg-background text-muted-foreground"
|
||||
data-testid="allow-document-rejection-trigger"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
|
||||
<SelectContent>
|
||||
<SelectItem value="true">
|
||||
<Trans>Yes</Trans>
|
||||
</SelectItem>
|
||||
|
||||
<SelectItem value="false">
|
||||
<Trans>No</Trans>
|
||||
</SelectItem>
|
||||
|
||||
{canInherit && (
|
||||
<SelectItem value={'-1'}>
|
||||
<Trans>Inherit from organisation</Trans>
|
||||
</SelectItem>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<FormDescription>
|
||||
<Trans>
|
||||
Controls whether recipients can reject newly created documents from the signing page. Existing
|
||||
documents keep their current setting. You can change it per document in the document settings.
|
||||
</Trans>
|
||||
</FormDescription>
|
||||
</InheritableField>
|
||||
)}
|
||||
/>
|
||||
|
||||
{isAiFeaturesConfigured && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
|
||||
@@ -1,282 +0,0 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -138,6 +138,12 @@ export const AdminGlobalSettingsSection = ({
|
||||
</DetailsValue>
|
||||
</DetailsCard>
|
||||
|
||||
<DetailsCard label={<Trans>Allow document rejection</Trans>}>
|
||||
<DetailsValue>
|
||||
{booleanValue(settings.allowDocumentRejection, inheritedSettings?.allowDocumentRejection)}
|
||||
</DetailsValue>
|
||||
</DetailsCard>
|
||||
|
||||
<DetailsCard label={<Trans>Typed signature</Trans>}>
|
||||
<DetailsValue>
|
||||
{booleanValue(settings.typedSignatureEnabled, inheritedSettings?.typedSignatureEnabled)}
|
||||
|
||||
@@ -1,332 +0,0 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
+3
-1
@@ -261,7 +261,9 @@ export const DocumentSigningPageViewV1 = ({
|
||||
|
||||
<div className="flex items-center gap-x-4">
|
||||
<DocumentSigningAttachmentsPopover envelopeId={document.envelopeId} token={recipient.token} />
|
||||
<DocumentSigningRejectDialog documentId={document.id} token={recipient.token} />
|
||||
{documentMeta.allowDocumentRejection && (
|
||||
<DocumentSigningRejectDialog documentId={document.id} token={recipient.token} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
+5
-1
@@ -63,6 +63,10 @@ export const DocumentSigningPageViewV2 = () => {
|
||||
onDocumentRejected,
|
||||
} = useEmbedSigningContext() || {};
|
||||
|
||||
// Both the document settings and the embed host (if any) must allow rejection.
|
||||
const isDocumentRejectionAllowed =
|
||||
envelope.type === EnvelopeType.DOCUMENT && envelope.documentMeta.allowDocumentRejection && allowDocumentRejection;
|
||||
|
||||
const { t } = useLingui();
|
||||
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false);
|
||||
|
||||
@@ -193,7 +197,7 @@ export const DocumentSigningPageViewV2 = () => {
|
||||
}
|
||||
/>
|
||||
|
||||
{envelope.type === EnvelopeType.DOCUMENT && allowDocumentRejection && (
|
||||
{isDocumentRejectionAllowed && (
|
||||
<DocumentSigningRejectDialog
|
||||
documentId={mapSecondaryIdToDocumentId(envelope.secondaryId)}
|
||||
token={recipient.token}
|
||||
|
||||
@@ -110,6 +110,7 @@ export const ZAddSettingsFormSchema = z.object({
|
||||
signatureTypes: z.array(z.nativeEnum(DocumentSignatureType)).min(1, {
|
||||
message: msg`At least one signature type must be enabled`.id,
|
||||
}),
|
||||
allowDocumentRejection: z.boolean(),
|
||||
envelopeExpirationPeriod: ZEnvelopeExpirationPeriod.nullish(),
|
||||
reminderSettings: ZEnvelopeReminderSettings.nullish(),
|
||||
}),
|
||||
@@ -200,6 +201,7 @@ export const EnvelopeEditorSettingsDialog = ({ trigger, ...props }: EnvelopeEdit
|
||||
emailReplyTo: envelope.documentMeta.emailReplyTo ?? undefined,
|
||||
emailSettings: ZDocumentEmailSettingsSchema.parse(envelope.documentMeta.emailSettings),
|
||||
signatureTypes: extractTeamSignatureSettings(envelope.documentMeta),
|
||||
allowDocumentRejection: envelope.documentMeta.allowDocumentRejection,
|
||||
envelopeExpirationPeriod: envelope.documentMeta?.envelopeExpirationPeriod ?? null,
|
||||
reminderSettings: envelope.documentMeta?.reminderSettings ?? null,
|
||||
},
|
||||
@@ -249,6 +251,7 @@ export const EnvelopeEditorSettingsDialog = ({ trigger, ...props }: EnvelopeEdit
|
||||
message,
|
||||
subject,
|
||||
emailReplyTo,
|
||||
allowDocumentRejection,
|
||||
envelopeExpirationPeriod,
|
||||
reminderSettings,
|
||||
} = data.meta;
|
||||
@@ -278,6 +281,7 @@ export const EnvelopeEditorSettingsDialog = ({ trigger, ...props }: EnvelopeEdit
|
||||
drawSignatureEnabled: signatureTypes.includes(DocumentSignatureType.DRAW),
|
||||
typedSignatureEnabled: signatureTypes.includes(DocumentSignatureType.TYPE),
|
||||
uploadSignatureEnabled: signatureTypes.includes(DocumentSignatureType.UPLOAD),
|
||||
allowDocumentRejection,
|
||||
envelopeExpirationPeriod,
|
||||
reminderSettings,
|
||||
},
|
||||
@@ -465,6 +469,52 @@ export const EnvelopeEditorSettingsDialog = ({ trigger, ...props }: EnvelopeEdit
|
||||
/>
|
||||
)}
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="meta.allowDocumentRejection"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="flex flex-row items-center">
|
||||
<Trans>Allow Document Rejection</Trans>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<InfoIcon className="mx-2 h-4 w-4" />
|
||||
</TooltipTrigger>
|
||||
|
||||
<TooltipContent className="max-w-xs text-muted-foreground">
|
||||
<Trans>
|
||||
Controls whether recipients can reject the document from the signing page.
|
||||
</Trans>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<Select
|
||||
value={field.value ? 'true' : 'false'}
|
||||
onValueChange={(value) => field.onChange(value === 'true')}
|
||||
>
|
||||
<SelectTrigger className="bg-background" data-testid="allow-document-rejection-trigger">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
|
||||
<SelectContent>
|
||||
<SelectItem value="true">
|
||||
<Trans>Yes</Trans>
|
||||
</SelectItem>
|
||||
|
||||
<SelectItem value="false">
|
||||
<Trans>No</Trans>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{settings.allowConfigureDateFormat && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
|
||||
@@ -87,7 +87,11 @@ export const EnvelopeSignerHeader = () => {
|
||||
const MobileDropdownMenu = () => {
|
||||
const { envelope, recipient } = useRequiredEnvelopeSigningContext();
|
||||
|
||||
const { allowDocumentRejection } = useEmbedSigningContext() || {};
|
||||
const { allowDocumentRejection = true } = useEmbedSigningContext() || {};
|
||||
|
||||
// Both the document settings and the embed host (if any) must allow rejection.
|
||||
const isDocumentRejectionAllowed =
|
||||
envelope.type === EnvelopeType.DOCUMENT && envelope.documentMeta.allowDocumentRejection && allowDocumentRejection;
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
@@ -113,7 +117,7 @@ const MobileDropdownMenu = () => {
|
||||
}
|
||||
/>
|
||||
|
||||
{envelope.type === EnvelopeType.DOCUMENT && allowDocumentRejection !== false && (
|
||||
{isDocumentRejectionAllowed && (
|
||||
<DocumentSigningRejectDialog
|
||||
documentId={mapSecondaryIdToDocumentId(envelope.secondaryId)}
|
||||
token={recipient.token}
|
||||
|
||||
@@ -1,144 +0,0 @@
|
||||
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,7 +6,6 @@ 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';
|
||||
@@ -101,23 +100,6 @@ 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 }) => (
|
||||
|
||||
+1
-14
@@ -3,10 +3,8 @@ 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';
|
||||
@@ -65,19 +63,9 @@ 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, 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 }),
|
||||
]);
|
||||
organisations = await getOrganisationSession({ userId: session.user.id });
|
||||
}
|
||||
|
||||
return data(
|
||||
@@ -95,7 +83,6 @@ export async function loader({ context, request }: Route.LoaderArgs) {
|
||||
user: session.user,
|
||||
session: session.session,
|
||||
organisations: organisations || [],
|
||||
twoFactorEnforcement,
|
||||
}
|
||||
: null,
|
||||
publicEnv: createPublicEnv(),
|
||||
|
||||
@@ -1,44 +1,31 @@
|
||||
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 { useEffect } from 'react';
|
||||
import { Link, Outlet, redirect, useLocation, useNavigate } from 'react-router';
|
||||
import { Link, Outlet, redirect } 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';
|
||||
|
||||
/**
|
||||
* Builds the enrolment redirect for an instance-blocked user, carrying the
|
||||
* current path so they land back where they were after enrolling.
|
||||
* Don't revalidate (run the loader on sequential navigations)
|
||||
*
|
||||
* Update values via providers.
|
||||
*/
|
||||
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 const shouldRevalidate = () => false;
|
||||
|
||||
export async function loader({ request }: Route.LoaderArgs) {
|
||||
const [session, banner] = await Promise.all([
|
||||
@@ -50,22 +37,6 @@ 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,
|
||||
};
|
||||
@@ -74,51 +45,7 @@ export async function loader({ request }: Route.LoaderArgs) {
|
||||
export default function Layout({ loaderData, params, matches }: Route.ComponentProps) {
|
||||
const { banner } = loaderData;
|
||||
|
||||
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 { user, organisations } = useSession();
|
||||
|
||||
const { layoutMode } = useChildRouteFlags();
|
||||
|
||||
@@ -153,65 +80,6 @@ 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
|
||||
@@ -244,8 +112,6 @@ 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,7 +6,6 @@ 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';
|
||||
|
||||
@@ -32,8 +31,6 @@ export default function AdminSiteSettingsPage({ loaderData }: Route.ComponentPro
|
||||
<AdminSiteBannerSection banner={banner} />
|
||||
|
||||
<AdminEmailBlocklistSection emailBlocklist={emailBlocklist} />
|
||||
|
||||
<AdminTwoFactorEnforcementSection />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
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';
|
||||
@@ -76,11 +75,6 @@ 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();
|
||||
@@ -234,7 +228,7 @@ const AdminUserPage = ({ user }: { user: TGetUserResponse }) => {
|
||||
</Accordion>
|
||||
|
||||
<div className="mt-16 flex flex-col gap-4">
|
||||
{canResetTwoFactor && <AdminUserResetTwoFactorDialog user={user} />}
|
||||
{user && user.twoFactorEnabled && <AdminUserResetTwoFactorDialog user={user} />}
|
||||
{user && user.disabled && <AdminUserEnableDialog userToEnable={user} />}
|
||||
{user && !user.disabled && <AdminUserDisableDialog userToDisable={user} />}
|
||||
{user && <AdminUserDeleteDialog user={user} />}
|
||||
|
||||
@@ -39,6 +39,7 @@ export default function OrganisationSettingsDocumentPage() {
|
||||
signatureTypes,
|
||||
defaultRecipients,
|
||||
delegateDocumentOwnership,
|
||||
allowDocumentRejection,
|
||||
aiFeaturesEnabled,
|
||||
} = data;
|
||||
|
||||
@@ -46,6 +47,7 @@ export default function OrganisationSettingsDocumentPage() {
|
||||
documentVisibility === null ||
|
||||
documentLanguage === null ||
|
||||
documentDateFormat === null ||
|
||||
allowDocumentRejection === null ||
|
||||
aiFeaturesEnabled === null
|
||||
) {
|
||||
throw new Error('Should not be possible.');
|
||||
@@ -63,6 +65,7 @@ export default function OrganisationSettingsDocumentPage() {
|
||||
uploadSignatureEnabled: signatureTypes.includes(DocumentSignatureType.UPLOAD),
|
||||
drawSignatureEnabled: signatureTypes.includes(DocumentSignatureType.DRAW),
|
||||
delegateDocumentOwnership,
|
||||
allowDocumentRejection,
|
||||
aiFeaturesEnabled,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -7,7 +7,6 @@ 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';
|
||||
@@ -30,19 +29,6 @@ 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">
|
||||
|
||||
@@ -33,6 +33,7 @@ export default function TeamsSettingsPage() {
|
||||
signatureTypes,
|
||||
defaultRecipients,
|
||||
delegateDocumentOwnership,
|
||||
allowDocumentRejection,
|
||||
aiFeaturesEnabled,
|
||||
} = data;
|
||||
|
||||
@@ -57,6 +58,7 @@ export default function TeamsSettingsPage() {
|
||||
drawSignatureEnabled: signatureTypes.includes(DocumentSignatureType.DRAW),
|
||||
}),
|
||||
delegateDocumentOwnership,
|
||||
allowDocumentRejection,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -1,244 +0,0 @@
|
||||
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,12 +32,6 @@ export async function loader({ params, request }: Route.LoaderArgs) {
|
||||
organisation: {
|
||||
select: {
|
||||
name: true,
|
||||
organisationGlobalSettings: {
|
||||
select: {
|
||||
twoFactorRequired: true,
|
||||
twoFactorGracePeriodDays: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -77,10 +71,6 @@ 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,
|
||||
@@ -88,8 +78,6 @@ 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;
|
||||
}
|
||||
|
||||
@@ -153,8 +141,6 @@ export default function AcceptInvitationPage({ loaderData }: Route.ComponentProp
|
||||
organisationName={data.organisationName}
|
||||
userExists={data.userExists}
|
||||
isSessionUserTheInvitedUser={data.isSessionUserTheInvitedUser}
|
||||
organisationTwoFactorRequired={data.organisationTwoFactorRequired}
|
||||
organisationTwoFactorGracePeriodDays={data.organisationTwoFactorGracePeriodDays}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -165,8 +151,6 @@ type PendingInvitationProps = {
|
||||
organisationName: string;
|
||||
userExists: boolean;
|
||||
isSessionUserTheInvitedUser: boolean;
|
||||
organisationTwoFactorRequired: boolean;
|
||||
organisationTwoFactorGracePeriodDays: number;
|
||||
};
|
||||
|
||||
type InvitationResult = 'idle' | 'accepted' | 'declined';
|
||||
@@ -179,8 +163,6 @@ const PendingInvitation = ({
|
||||
organisationName,
|
||||
userExists,
|
||||
isSessionUserTheInvitedUser,
|
||||
organisationTwoFactorRequired,
|
||||
organisationTwoFactorGracePeriodDays,
|
||||
}: PendingInvitationProps) => {
|
||||
const { t } = useLingui();
|
||||
const { toast } = useToast();
|
||||
@@ -307,24 +289,6 @@ 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,12 +86,6 @@ export async function loader({ params }: Route.LoaderArgs) {
|
||||
name: true,
|
||||
url: true,
|
||||
avatarImageId: true,
|
||||
organisationGlobalSettings: {
|
||||
select: {
|
||||
twoFactorRequired: true,
|
||||
twoFactorGracePeriodDays: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -113,10 +107,6 @@ 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;
|
||||
}
|
||||
@@ -276,26 +266,6 @@ 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,9 +137,6 @@ 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 (
|
||||
|
||||
@@ -1,385 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
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,7 +1,6 @@
|
||||
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';
|
||||
@@ -42,14 +41,6 @@ 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,7 +1,6 @@
|
||||
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';
|
||||
@@ -42,14 +41,6 @@ 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,7 +1,6 @@
|
||||
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';
|
||||
@@ -29,17 +28,6 @@ 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) {
|
||||
@@ -66,13 +54,6 @@ 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);
|
||||
}
|
||||
})
|
||||
@@ -88,10 +69,6 @@ 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,
|
||||
@@ -109,11 +86,6 @@ export const filesRoute = new Hono<HonoEnv>()
|
||||
id: envelopeId,
|
||||
},
|
||||
include: {
|
||||
team: {
|
||||
select: {
|
||||
organisationId: true,
|
||||
},
|
||||
},
|
||||
envelopeItems: {
|
||||
where: {
|
||||
id: envelopeItemId,
|
||||
@@ -129,26 +101,6 @@ 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) {
|
||||
@@ -200,11 +152,6 @@ export const filesRoute = new Hono<HonoEnv>()
|
||||
id: envelopeId,
|
||||
},
|
||||
include: {
|
||||
team: {
|
||||
select: {
|
||||
organisationId: true,
|
||||
},
|
||||
},
|
||||
envelopeItems: {
|
||||
where: {
|
||||
id: envelopeItemId,
|
||||
@@ -226,15 +173,6 @@ 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,6 +1,4 @@
|
||||
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';
|
||||
@@ -43,10 +41,6 @@ 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({
|
||||
@@ -75,11 +69,6 @@ route.get(
|
||||
type: true,
|
||||
teamId: true,
|
||||
templateType: true,
|
||||
team: {
|
||||
select: {
|
||||
organisationId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -89,26 +78,6 @@ 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,
|
||||
|
||||
Generated
+3681
-1504
File diff suppressed because it is too large
Load Diff
+2
-17
@@ -46,21 +46,6 @@
|
||||
"npm": ">=11.17.0",
|
||||
"node": ">=24.0.0"
|
||||
},
|
||||
"allowScripts": {
|
||||
"@documenso/skia-canvas": true,
|
||||
"@playwright/browser-chromium": true,
|
||||
"@prisma/client": true,
|
||||
"@prisma/engines": true,
|
||||
"aws-crt": true,
|
||||
"esbuild": true,
|
||||
"fsevents": true,
|
||||
"inngest-cli": true,
|
||||
"prisma": true,
|
||||
"@datadog/pprof": false,
|
||||
"core-js": false,
|
||||
"msgpackr-extract": false,
|
||||
"protobufjs": false
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.4.8",
|
||||
"@types/react": "^19.2.17",
|
||||
@@ -82,7 +67,7 @@
|
||||
"esbuild": "^0.28.1",
|
||||
"husky": "^9.1.7",
|
||||
"inngest": "^3.54.0",
|
||||
"inngest-cli": "^1.44.0",
|
||||
"inngest-cli": "^1.17.9",
|
||||
"lint-staged": "^16.2.7",
|
||||
"nanoid": "^5.1.6",
|
||||
"nodemailer": "^9.0.0",
|
||||
@@ -145,7 +130,7 @@
|
||||
"posthog-node": "4.18.0",
|
||||
"react": "^19.2.7",
|
||||
"react-dom": "^19.2.7",
|
||||
"sharp": "0.35.4",
|
||||
"sharp": "0.35.3",
|
||||
"typescript": "5.6.2",
|
||||
"@marsidev/react-turnstile": "^1.5.0",
|
||||
"zod": "^3.25.76"
|
||||
|
||||
@@ -24,6 +24,7 @@ const TEST_SETTINGS_VALUES = {
|
||||
subject: 'E2E settings subject',
|
||||
message: 'E2E settings message',
|
||||
language: 'French',
|
||||
allowDocumentRejection: 'No',
|
||||
dateFormat: 'DD/MM/YYYY',
|
||||
timezone: 'Europe/London',
|
||||
distributionMethod: 'None',
|
||||
@@ -104,6 +105,10 @@ const runSettingsFlow = async ({ root }: TEnvelopeEditorSurface, { externalId, i
|
||||
await root.getByRole('option', { name: 'Upload' }).click();
|
||||
await clickSettingsDialogHeader(root);
|
||||
|
||||
await getComboboxByLabel(root, 'Allow Document Rejection').click();
|
||||
await root.getByRole('option', { name: TEST_SETTINGS_VALUES.allowDocumentRejection, exact: true }).click();
|
||||
await clickSettingsDialogHeader(root);
|
||||
|
||||
await getComboboxByLabel(root, 'Date Format').click();
|
||||
await root.getByRole('option', { name: TEST_SETTINGS_VALUES.dateFormat, exact: true }).click();
|
||||
await clickSettingsDialogHeader(root);
|
||||
@@ -265,6 +270,9 @@ const runSettingsFlow = async ({ root }: TEnvelopeEditorSurface, { externalId, i
|
||||
await expect(root.locator('input[name="meta.redirectUrl"]')).toHaveValue(TEST_SETTINGS_VALUES.redirectUrl);
|
||||
await expect(getComboboxByLabel(root, 'Language')).toContainText(TEST_SETTINGS_VALUES.language);
|
||||
await expect(getComboboxByLabel(root, 'Allowed Signature Types')).not.toContainText('Upload');
|
||||
await expect(getComboboxByLabel(root, 'Allow Document Rejection')).toContainText(
|
||||
TEST_SETTINGS_VALUES.allowDocumentRejection,
|
||||
);
|
||||
await expect(getComboboxByLabel(root, 'Date Format')).toContainText(TEST_SETTINGS_VALUES.dateFormat);
|
||||
await expect(getComboboxByLabel(root, 'Time Zone')).toContainText(TEST_SETTINGS_VALUES.timezone);
|
||||
await expect(root.locator('[data-testid="documentDistributionMethodSelectValue"]')).toContainText(
|
||||
@@ -384,6 +392,7 @@ const assertEnvelopeSettingsPersistedInDatabase = async ({
|
||||
expect(envelope.documentMeta.drawSignatureEnabled).toBe(true);
|
||||
expect(envelope.documentMeta.typedSignatureEnabled).toBe(true);
|
||||
expect(envelope.documentMeta.uploadSignatureEnabled).toBe(false);
|
||||
expect(envelope.documentMeta.allowDocumentRejection).toBe(false);
|
||||
expect(envelope.documentMeta.emailSettings).toMatchObject(DB_EXPECTED_VALUES.emailSettings);
|
||||
|
||||
const authOptions = parseAuthOptions(envelope.authOptions);
|
||||
|
||||
@@ -6,12 +6,6 @@ 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.
|
||||
*/
|
||||
@@ -22,7 +16,6 @@ export const apiSignin = async ({
|
||||
page,
|
||||
email = 'example@documenso.com',
|
||||
password = 'password',
|
||||
totpCode,
|
||||
redirectPath = '/',
|
||||
}: LoginOptions) => {
|
||||
const { request } = page.context();
|
||||
@@ -33,7 +26,6 @@ export const apiSignin = async ({
|
||||
data: {
|
||||
email,
|
||||
password,
|
||||
totpCode,
|
||||
csrfToken,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,111 +0,0 @@
|
||||
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,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -37,6 +37,10 @@ test('[ORGANISATIONS]: manage document preferences', async ({ page }) => {
|
||||
await page.getByRole('option', { name: 'Upload' }).click();
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
// Disable document rejection
|
||||
await page.getByTestId('allow-document-rejection-trigger').click();
|
||||
await page.getByRole('option', { name: 'No', exact: true }).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Save changes' }).first().click();
|
||||
await expect(page.getByText('Your document preferences have been updated').first()).toBeVisible();
|
||||
|
||||
@@ -68,10 +72,14 @@ test('[ORGANISATIONS]: manage document preferences', async ({ page }) => {
|
||||
expect(teamSettings.typedSignatureEnabled).toEqual(true);
|
||||
expect(teamSettings.uploadSignatureEnabled).toEqual(false);
|
||||
expect(teamSettings.drawSignatureEnabled).toEqual(false);
|
||||
expect(teamSettings.allowDocumentRejection).toEqual(false);
|
||||
|
||||
// Edit the team settings
|
||||
await page.goto(`/t/${team.url}/settings/document`);
|
||||
|
||||
// Document rejection is left untouched so it keeps inheriting from the organisation.
|
||||
await expect(page.getByTestId('allow-document-rejection-status')).toHaveText('Inherited');
|
||||
|
||||
await page.getByTestId('document-visibility-trigger').click();
|
||||
await page.getByRole('option', { name: 'Everyone can access and view' }).click();
|
||||
await page.getByTestId('document-language-trigger').click();
|
||||
@@ -102,6 +110,7 @@ test('[ORGANISATIONS]: manage document preferences', async ({ page }) => {
|
||||
expect(updatedTeamSettings.typedSignatureEnabled).toEqual(true);
|
||||
expect(updatedTeamSettings.uploadSignatureEnabled).toEqual(false);
|
||||
expect(updatedTeamSettings.drawSignatureEnabled).toEqual(false);
|
||||
expect(updatedTeamSettings.allowDocumentRejection).toEqual(false);
|
||||
|
||||
const document = await seedTeamDocumentWithMeta(team);
|
||||
|
||||
@@ -120,6 +129,7 @@ test('[ORGANISATIONS]: manage document preferences', async ({ page }) => {
|
||||
expect(documentMeta.language).toEqual('pl');
|
||||
expect(documentMeta.timezone).toEqual('Europe/London');
|
||||
expect(documentMeta.dateFormat).toEqual('MM/dd/yyyy');
|
||||
expect(documentMeta.allowDocumentRejection).toEqual(false);
|
||||
});
|
||||
|
||||
test('[ORGANISATIONS]: manage branding preferences', async ({ page }) => {
|
||||
|
||||
@@ -1,272 +0,0 @@
|
||||
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);
|
||||
});
|
||||
@@ -1,64 +0,0 @@
|
||||
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,14 +5,13 @@ 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 {
|
||||
@@ -30,8 +29,9 @@ type TEmailPasswordSignin = InferRequestType<AuthClientType['email-password']['a
|
||||
redirectPath?: string;
|
||||
};
|
||||
|
||||
// `redirectPath` is part of the request schema and validated server-side.
|
||||
type TPasskeySignin = InferRequestType<AuthClientType['passkey']['authorize']['$post']>['json'];
|
||||
type TPasskeySignin = InferRequestType<AuthClientType['passkey']['authorize']['$post']>['json'] & {
|
||||
redirectPath?: string;
|
||||
};
|
||||
|
||||
export class AuthClient {
|
||||
public client: AuthClientType;
|
||||
@@ -71,7 +71,7 @@ export class AuthClient {
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
return superjson.deserialize<TSessionJsonResponse>(result);
|
||||
return superjson.deserialize<SessionValidationResult>(result);
|
||||
}
|
||||
|
||||
public async getSessions() {
|
||||
@@ -146,20 +146,6 @@ 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);
|
||||
},
|
||||
|
||||
@@ -259,8 +245,6 @@ 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 });
|
||||
@@ -273,51 +257,6 @@ 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 = {
|
||||
@@ -330,11 +269,7 @@ export class AuthClient {
|
||||
throw AppError.parseError(error);
|
||||
}
|
||||
|
||||
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);
|
||||
handleSignInRedirect(data.redirectPath);
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -17,9 +17,6 @@ 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');
|
||||
|
||||
export const getAuthSecret = () => {
|
||||
const getAuthSecret = () => {
|
||||
const authSecret = env('NEXTAUTH_SECRET');
|
||||
|
||||
if (!authSecret) {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
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';
|
||||
@@ -15,16 +14,7 @@ import { AUTH_SESSION_LIFETIME } from '../../config';
|
||||
*/
|
||||
export type SessionUser = Pick<
|
||||
User,
|
||||
| 'id'
|
||||
| 'name'
|
||||
| 'email'
|
||||
| 'emailVerified'
|
||||
| 'avatarImageId'
|
||||
| 'twoFactorEnabled'
|
||||
| 'twoFactorGraceStartedAt'
|
||||
| 'roles'
|
||||
| 'signature'
|
||||
| 'disabled'
|
||||
'id' | 'name' | 'email' | 'emailVerified' | 'avatarImageId' | 'twoFactorEnabled' | 'roles' | 'signature' | 'disabled'
|
||||
>;
|
||||
|
||||
export type SessionValidationResult =
|
||||
@@ -45,25 +35,7 @@ export const generateSessionToken = (): string => {
|
||||
return token;
|
||||
};
|
||||
|
||||
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> => {
|
||||
export const createSession = async (token: string, userId: number, metadata: RequestMetadata): Promise<Session> => {
|
||||
const hashedSessionId = encodeHexLowerCase(sha256(new TextEncoder().encode(token)));
|
||||
|
||||
const session: Session = {
|
||||
@@ -75,8 +47,6 @@ export const createSession = async (
|
||||
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({
|
||||
@@ -114,7 +84,6 @@ export const validateSessionToken = async (token: string): Promise<SessionValida
|
||||
emailVerified: true,
|
||||
avatarImageId: true,
|
||||
twoFactorEnabled: true,
|
||||
twoFactorGraceStartedAt: true,
|
||||
roles: true,
|
||||
signature: true,
|
||||
disabled: true,
|
||||
|
||||
@@ -1,26 +1,12 @@
|
||||
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;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -34,20 +20,11 @@ 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, {
|
||||
authMethod: user.authMethod,
|
||||
twoFactorVerified: user.twoFactorVerified,
|
||||
});
|
||||
await createSession(sessionToken, user.userId, metadata);
|
||||
|
||||
await setSessionCookie(c, sessionToken);
|
||||
};
|
||||
|
||||
@@ -59,8 +59,6 @@ export const getActiveSessions = async (c: Context | Request): Promise<ActiveSes
|
||||
createdAt: true,
|
||||
ipAddress: true,
|
||||
userAgent: true,
|
||||
authMethod: true,
|
||||
twoFactorVerified: true,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -20,7 +20,6 @@ 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;
|
||||
@@ -51,7 +50,6 @@ export const handleOAuthCallbackUrl = async (options: HandleOAuthCallbackUrlOpti
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
twoFactorEnabled: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -59,21 +57,7 @@ export const handleOAuthCallbackUrl = async (options: HandleOAuthCallbackUrlOpti
|
||||
|
||||
// Directly log in user if account already exists.
|
||||
if (existingAccount) {
|
||||
// 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);
|
||||
await onAuthorize({ userId: existingAccount.user.id }, c);
|
||||
|
||||
return c.redirect(redirectPath, 302);
|
||||
}
|
||||
@@ -85,37 +69,11 @@ 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: {
|
||||
@@ -156,7 +114,7 @@ export const handleOAuthCallbackUrl = async (options: HandleOAuthCallbackUrlOpti
|
||||
}
|
||||
});
|
||||
|
||||
await onAuthorize({ userId: userWithSameEmail.id, authMethod: 'oauth', twoFactorVerified: false }, c);
|
||||
await onAuthorize({ userId: userWithSameEmail.id }, c);
|
||||
|
||||
return c.redirect(redirectPath, 302);
|
||||
}
|
||||
@@ -221,7 +179,7 @@ export const handleOAuthCallbackUrl = async (options: HandleOAuthCallbackUrlOpti
|
||||
console.error(err);
|
||||
});
|
||||
|
||||
await onAuthorize({ userId: createdUser.id, authMethod: 'oauth', twoFactorVerified: false }, c);
|
||||
await onAuthorize({ userId: createdUser.id }, c);
|
||||
|
||||
return c.redirect(redirectPath, 302);
|
||||
};
|
||||
|
||||
@@ -12,7 +12,6 @@ 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;
|
||||
@@ -27,7 +26,7 @@ export const handleOAuthOrganisationCallbackUrl = async (options: HandleOAuthOrg
|
||||
organisationUrl: orgUrl,
|
||||
});
|
||||
|
||||
const { email, name, sub } = await validateOauth({
|
||||
const { email, name, sub, accessToken, accessTokenExpiresAt, idToken } = await validateOauth({
|
||||
c,
|
||||
clientOptions: {
|
||||
...clientOptions,
|
||||
@@ -56,21 +55,7 @@ export const handleOAuthOrganisationCallbackUrl = async (options: HandleOAuthOrg
|
||||
|
||||
// Directly log in user if account already exists.
|
||||
if (existingAccount) {
|
||||
// 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);
|
||||
await onAuthorize({ userId: existingAccount.user.id }, c);
|
||||
|
||||
return c.redirect(formatPath(`/o/${orgUrl}`), 302);
|
||||
}
|
||||
@@ -118,16 +103,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),
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -1,377 +0,0 @@
|
||||
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,9 +7,12 @@ 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 { 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 { verifyCaptchaToken } from '@documenso/lib/server-only/captcha/verify-captcha';
|
||||
import { rateLimitResponse } from '@documenso/lib/server-only/rate-limit/rate-limit-middleware';
|
||||
import {
|
||||
@@ -22,7 +25,6 @@ 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';
|
||||
@@ -39,6 +41,7 @@ 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';
|
||||
@@ -133,16 +136,14 @@ export const emailPasswordRoute = new Hono<HonoAuthContext>()
|
||||
|
||||
const is2faEnabled = isTwoFactorAuthenticationEnabled({ user });
|
||||
|
||||
let isBackupCodeRecovery = false;
|
||||
|
||||
if (is2faEnabled) {
|
||||
const validationResult = await validateTwoFactorAuthentication({
|
||||
const isValid = await validateTwoFactorAuthentication({
|
||||
backupCode,
|
||||
totpCode,
|
||||
user,
|
||||
});
|
||||
|
||||
if (!validationResult.isValid) {
|
||||
if (!isValid) {
|
||||
await prisma.userSecurityAuditLog.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
@@ -154,8 +155,6 @@ export const emailPasswordRoute = new Hono<HonoAuthContext>()
|
||||
|
||||
throw new AppError(AuthenticationErrorCode.InvalidTwoFactorCode);
|
||||
}
|
||||
|
||||
isBackupCodeRecovery = validationResult.method === 'backup';
|
||||
}
|
||||
|
||||
if (!user.emailVerified) {
|
||||
@@ -181,44 +180,11 @@ 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.
|
||||
//
|
||||
// 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,
|
||||
);
|
||||
await onAuthorize({ userId: user.id }, c);
|
||||
|
||||
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,
|
||||
);
|
||||
return c.text('', 201);
|
||||
})
|
||||
/**
|
||||
* Signup endpoint.
|
||||
@@ -366,7 +332,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, authMethod: 'email-password', twoFactorVerified: false }, c);
|
||||
await onAuthorize({ userId }, c);
|
||||
}
|
||||
|
||||
return c.json({
|
||||
@@ -504,8 +470,156 @@ 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);
|
||||
|
||||
// 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.
|
||||
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,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -6,7 +6,6 @@ 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';
|
||||
@@ -38,7 +37,7 @@ export const passkeyRoute = new Hono<HonoAuthContext>()
|
||||
});
|
||||
}
|
||||
|
||||
const { csrfToken, credential, redirectPath } = c.req.valid('json');
|
||||
const { csrfToken, credential } = c.req.valid('json');
|
||||
|
||||
if (typeof csrfToken !== 'string' || csrfToken.length === 0) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST);
|
||||
@@ -105,12 +104,6 @@ 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),
|
||||
@@ -141,17 +134,11 @@ export const passkeyRoute = new Hono<HonoAuthContext>()
|
||||
},
|
||||
});
|
||||
|
||||
// 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) ?? '/') : '/';
|
||||
await onAuthorize({ userId: user.id }, c);
|
||||
|
||||
return c.json(
|
||||
{
|
||||
url,
|
||||
url: '/',
|
||||
},
|
||||
200,
|
||||
);
|
||||
|
||||
@@ -1,20 +1,9 @@
|
||||
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);
|
||||
@@ -29,11 +18,5 @@ export const sessionRoute = new Hono()
|
||||
.get('/session-json', async (c) => {
|
||||
const session: SessionValidationResult = await getOptionalSession(c);
|
||||
|
||||
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));
|
||||
return c.json(superjson.serialize(session));
|
||||
});
|
||||
|
||||
@@ -1,36 +1,18 @@
|
||||
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';
|
||||
|
||||
@@ -58,7 +40,7 @@ export const twoFactorRoute = new Hono<HonoAuthContext>()
|
||||
.post('/enable', sValidator('json', ZEnableTwoFactorRequestSchema), async (c) => {
|
||||
const requestMetadata = c.get('requestMetadata');
|
||||
|
||||
const { user: sessionUser, session } = await getSession(c);
|
||||
const { user: sessionUser } = await getSession(c);
|
||||
|
||||
const user = await prisma.user.findFirst({
|
||||
where: {
|
||||
@@ -81,7 +63,6 @@ export const twoFactorRoute = new Hono<HonoAuthContext>()
|
||||
const result = await enableTwoFactorAuthentication({
|
||||
user,
|
||||
code,
|
||||
sessionId: session.id,
|
||||
requestMetadata,
|
||||
});
|
||||
|
||||
@@ -118,24 +99,14 @@ export const twoFactorRoute = new Hono<HonoAuthContext>()
|
||||
|
||||
const { totpCode, backupCode } = c.req.valid('json');
|
||||
|
||||
const { orgEnforcementApplies } = await disableTwoFactorAuthentication({
|
||||
await disableTwoFactorAuthentication({
|
||||
user,
|
||||
totpCode,
|
||||
backupCode,
|
||||
requestMetadata,
|
||||
});
|
||||
|
||||
// `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,
|
||||
);
|
||||
return c.text('OK', 201);
|
||||
})
|
||||
|
||||
/**
|
||||
@@ -172,235 +143,4 @@ 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,17 +18,3 @@ 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,12 +3,6 @@ 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,12 +5,11 @@ import {
|
||||
ORGANISATION_USER_ACCOUNT_TYPE,
|
||||
} from '@documenso/lib/constants/organisations';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { jobs } from '@documenso/lib/jobs/client';
|
||||
import { addUserToOrganisation } from '@documenso/lib/server-only/organisation/accept-organisation-invitation';
|
||||
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 { OrganisationGroupType, UserSecurityAuditLogType } from '@prisma/client';
|
||||
import { UserSecurityAuditLogType } from '@prisma/client';
|
||||
|
||||
export interface LinkOrganisationAccountOptions {
|
||||
token: string;
|
||||
@@ -24,10 +23,8 @@ export const linkOrganisationAccount = async ({ token, requestMeta }: LinkOrgani
|
||||
});
|
||||
}
|
||||
|
||||
// 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({
|
||||
// Delete the token since it contains unnecessary sensitive data.
|
||||
const verificationToken = await prisma.verificationToken.delete({
|
||||
where: {
|
||||
token,
|
||||
identifier: ORGANISATION_ACCOUNT_LINK_VERIFICATION_TOKEN_IDENTIFIER,
|
||||
@@ -36,9 +33,13 @@ export const linkOrganisationAccount = async ({ token, requestMeta }: LinkOrgani
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
emailVerified: true,
|
||||
disabled: true,
|
||||
accounts: {
|
||||
select: {
|
||||
provider: true,
|
||||
providerAccountId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -72,47 +73,11 @@ 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,
|
||||
@@ -120,34 +85,32 @@ export const linkOrganisationAccount = async ({ token, requestMeta }: LinkOrgani
|
||||
},
|
||||
});
|
||||
|
||||
const isProviderAccountLinked = existingProviderAccount !== null;
|
||||
const oauthConfig = tokenMetadata.data.oauthConfig;
|
||||
|
||||
// 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) {
|
||||
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) {
|
||||
await prisma.$transaction(async (tx) => {
|
||||
const createdAccounts = await tx.account.createMany({
|
||||
data: [
|
||||
{
|
||||
type: ORGANISATION_USER_ACCOUNT_TYPE,
|
||||
provider: clientOptions.id,
|
||||
providerAccountId,
|
||||
userId: user.id,
|
||||
},
|
||||
],
|
||||
skipDuplicates: true,
|
||||
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,
|
||||
},
|
||||
});
|
||||
|
||||
// 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: {
|
||||
@@ -176,66 +139,15 @@ export const linkOrganisationAccount = async ({ token, requestMeta }: LinkOrgani
|
||||
});
|
||||
}
|
||||
|
||||
// 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.
|
||||
// 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) {
|
||||
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);
|
||||
}
|
||||
await addUserToOrganisation({
|
||||
userId: user.id,
|
||||
organisationId: tokenMetadata.data.organisationId,
|
||||
organisationGroups: organisation.groups,
|
||||
organisationMemberRole: organisation.organisationAuthenticationPortal.defaultOrganisationRole,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -14,7 +14,7 @@ import crypto from 'crypto';
|
||||
import { DateTime } from 'luxon';
|
||||
import { createElement } from 'react';
|
||||
|
||||
export type SendOrganisationAccountLinkConfirmationEmailProps = Omit<TOrganisationAccountLinkMetadata, 'email'> & {
|
||||
export type SendOrganisationAccountLinkConfirmationEmailProps = TOrganisationAccountLinkMetadata & {
|
||||
organisationName: string;
|
||||
};
|
||||
|
||||
@@ -69,9 +69,6 @@ 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,19 +8,11 @@ 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 {
|
||||
@@ -102,7 +94,6 @@ export const SessionProvider = ({ children, initialSession }: SessionProviderPro
|
||||
session: newSession.session,
|
||||
user: newSession.user,
|
||||
organisations,
|
||||
twoFactorEnforcement: newSession.twoFactorEnforcement,
|
||||
});
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -31,12 +31,6 @@ 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,9 +113,6 @@ 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 },
|
||||
@@ -344,7 +341,7 @@ export class AppError extends Error {
|
||||
() => 400 as const,
|
||||
)
|
||||
.with(AppErrorCode.UNAUTHORIZED, () => 401 as const)
|
||||
.with(AppErrorCode.FORBIDDEN, AppErrorCode.CSC_UNLICENSED, AppErrorCode.TWO_FACTOR_REQUIRED, () => 403 as const)
|
||||
.with(AppErrorCode.FORBIDDEN, AppErrorCode.CSC_UNLICENSED, () => 403 as const)
|
||||
.with(AppErrorCode.NOT_FOUND, () => 404 as const)
|
||||
.with(AppErrorCode.NOT_IMPLEMENTED, () => 501 as const)
|
||||
.otherwise(() => 500 as const);
|
||||
|
||||
@@ -46,7 +46,7 @@
|
||||
"ai": "^7.0.58",
|
||||
"bullmq": "^5.71.1",
|
||||
"colord": "^2.9.3",
|
||||
"csv-parse": "^7.0.2",
|
||||
"csv-parse": "^6.1.0",
|
||||
"inngest": "^3.54.0",
|
||||
"ioredis": "^5.10.1",
|
||||
"jose": "^6.1.2",
|
||||
@@ -67,7 +67,7 @@
|
||||
"posthog-node": "4.18.0",
|
||||
"react": "^19.2.7",
|
||||
"remeda": "^2.32.0",
|
||||
"sharp": "0.35.4",
|
||||
"sharp": "0.35.3",
|
||||
"stripe": "^12.18.0",
|
||||
"ts-pattern": "^5.9.0",
|
||||
"zod": "^3.25.76"
|
||||
|
||||
@@ -4,7 +4,6 @@ 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 = {
|
||||
@@ -20,42 +19,22 @@ export const disableTwoFactorAuthentication = async ({
|
||||
user,
|
||||
requestMetadata,
|
||||
}: DisableTwoFactorAuthenticationOptions) => {
|
||||
let isValid = false;
|
||||
|
||||
if (!totpCode && !backupCode) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST);
|
||||
}
|
||||
|
||||
// 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,
|
||||
});
|
||||
if (totpCode) {
|
||||
isValid = await validateTwoFactorAuthentication({ totpCode, user });
|
||||
} else if (backupCode) {
|
||||
isValid = await validateTwoFactorAuthentication({ backupCode, user });
|
||||
}
|
||||
|
||||
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: {
|
||||
@@ -78,5 +57,5 @@ export const disableTwoFactorAuthentication = async ({
|
||||
});
|
||||
});
|
||||
|
||||
return { orgEnforcementApplies: orgEnforcementCount > 0 };
|
||||
return true;
|
||||
};
|
||||
|
||||
@@ -9,22 +9,12 @@ 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) {
|
||||
@@ -44,58 +34,21 @@ export const enableTwoFactorAuthentication = async ({
|
||||
let recoveryCodes: string[] = [];
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
// 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({
|
||||
const updatedUser = await tx.user.update({
|
||||
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,
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
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,
|
||||
}),
|
||||
};
|
||||
};
|
||||
@@ -1,52 +0,0 @@
|
||||
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;
|
||||
};
|
||||
@@ -1,53 +0,0 @@
|
||||
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(),
|
||||
});
|
||||
};
|
||||
@@ -1,191 +0,0 @@
|
||||
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 });
|
||||
};
|
||||
@@ -1,82 +0,0 @@
|
||||
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,7 +5,6 @@ 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 = {
|
||||
@@ -32,15 +31,9 @@ export const setupTwoFactorAuthentication = async ({ user }: SetupTwoFactorAuthe
|
||||
const uri = createTOTPKeyURI(ISSUER, accountName, secret);
|
||||
const encodedSecret = base32.encode(new Uint8Array(secret));
|
||||
|
||||
// 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({
|
||||
await prisma.user.update({
|
||||
where: {
|
||||
id: user.id,
|
||||
twoFactorEnabled: false,
|
||||
},
|
||||
data: {
|
||||
twoFactorEnabled: false,
|
||||
@@ -55,13 +48,6 @@ 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,
|
||||
|
||||
@@ -1,108 +0,0 @@
|
||||
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,21 +10,11 @@ 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): Promise<TValidateTwoFactorAuthenticationResult> => {
|
||||
}: ValidateTwoFactorAuthenticationOptions) => {
|
||||
if (!user.twoFactorEnabled) {
|
||||
throw new AppError(AppErrorCode.TWO_FACTOR_SETUP_REQUIRED);
|
||||
}
|
||||
@@ -34,15 +24,11 @@ export const validateTwoFactorAuthentication = async ({
|
||||
}
|
||||
|
||||
if (totpCode) {
|
||||
const isValid = await verifyTwoFactorAuthenticationToken({ user, totpCode });
|
||||
|
||||
return isValid ? { isValid: true, method: 'totp' } : { isValid: false, method: null };
|
||||
return await verifyTwoFactorAuthenticationToken({ user, totpCode });
|
||||
}
|
||||
|
||||
if (backupCode) {
|
||||
const isValid = verifyBackupCode({ user, backupCode });
|
||||
|
||||
return isValid ? { isValid: true, method: 'backup' } : { isValid: false, method: null };
|
||||
return verifyBackupCode({ user, backupCode });
|
||||
}
|
||||
|
||||
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,16 +13,7 @@ export const createPasskeySigninOptions = async ({ sessionId }: CreatePasskeySig
|
||||
|
||||
const options = await generateAuthenticationOptions({
|
||||
rpID: rpId,
|
||||
// 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',
|
||||
userVerification: 'preferred',
|
||||
timeout,
|
||||
});
|
||||
|
||||
|
||||
@@ -33,7 +33,11 @@ export async function rejectDocumentWithToken({ token, id, reason, requestMetada
|
||||
envelope: unsafeBuildEnvelopeIdQuery(id, EnvelopeType.DOCUMENT),
|
||||
},
|
||||
include: {
|
||||
envelope: true,
|
||||
envelope: {
|
||||
include: {
|
||||
documentMeta: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -51,6 +55,14 @@ export async function rejectDocumentWithToken({ token, id, reason, requestMetada
|
||||
});
|
||||
}
|
||||
|
||||
// Hiding the reject button on the signing page is not enough because the
|
||||
// recipient can call this endpoint directly, so enforce the setting here.
|
||||
if (!envelope.documentMeta.allowDocumentRejection) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: `Document ${envelope.id} does not allow rejection`,
|
||||
});
|
||||
}
|
||||
|
||||
assertRecipientNotExpired(recipient);
|
||||
|
||||
// Update the recipient status to rejected
|
||||
|
||||
@@ -48,6 +48,7 @@ export const ZEnvelopeForSigningResponse = z.object({
|
||||
uploadSignatureEnabled: true,
|
||||
drawSignatureEnabled: true,
|
||||
allowDictateNextSigner: true,
|
||||
allowDocumentRejection: true,
|
||||
language: true,
|
||||
}),
|
||||
recipients: ZRecipientLiteSchema.pick({
|
||||
|
||||
@@ -37,10 +37,6 @@ 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,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -53,11 +49,6 @@ 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,28 +59,6 @@ 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,13 +3,11 @@ 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>;
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
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>;
|
||||
@@ -113,6 +113,7 @@ export type CreateDocumentFromTemplateOptions = {
|
||||
uploadSignatureEnabled?: boolean;
|
||||
drawSignatureEnabled?: boolean;
|
||||
envelopeExpirationPeriod?: TEnvelopeExpirationPeriod | null;
|
||||
allowDocumentRejection?: boolean;
|
||||
};
|
||||
|
||||
formValues?: TDocumentFormValues;
|
||||
@@ -542,6 +543,7 @@ export const createDocumentFromTemplate = async ({
|
||||
drawSignatureEnabled: override?.drawSignatureEnabled ?? template.documentMeta?.drawSignatureEnabled,
|
||||
allowDictateNextSigner: override?.allowDictateNextSigner ?? template.documentMeta?.allowDictateNextSigner,
|
||||
envelopeExpirationPeriod: override?.envelopeExpirationPeriod ?? template.documentMeta?.envelopeExpirationPeriod,
|
||||
allowDocumentRejection: override?.allowDocumentRejection ?? template.documentMeta?.allowDocumentRejection,
|
||||
},
|
||||
signatureLevel,
|
||||
),
|
||||
|
||||
@@ -28,6 +28,7 @@ export const ZDocumentMetaSchema = DocumentMetaSchema.pick({
|
||||
typedSignatureEnabled: true,
|
||||
uploadSignatureEnabled: true,
|
||||
drawSignatureEnabled: true,
|
||||
allowDocumentRejection: true,
|
||||
language: true,
|
||||
emailSettings: true,
|
||||
});
|
||||
@@ -105,6 +106,12 @@ export const ZDocumentMetaUploadSignatureEnabledSchema = z
|
||||
.boolean()
|
||||
.describe('Whether to allow recipients to sign using an uploaded signature.');
|
||||
|
||||
export const ZDocumentMetaAllowDocumentRejectionSchema = z
|
||||
.boolean()
|
||||
.describe(
|
||||
'Whether recipients can reject the document from the signing page. Defaults to the organisation or team document preference. In embedded signing the embed must also enable its own allowDocumentRejection option.',
|
||||
);
|
||||
|
||||
/**
|
||||
* Note: Any updates to this will cause public API changes. You will need to update
|
||||
* all corresponding areas where this is used (some places that use this needs to pass
|
||||
@@ -123,6 +130,7 @@ export const ZDocumentMetaCreateSchema = z.object({
|
||||
typedSignatureEnabled: ZDocumentMetaTypedSignatureEnabledSchema.optional(),
|
||||
uploadSignatureEnabled: ZDocumentMetaUploadSignatureEnabledSchema.optional(),
|
||||
drawSignatureEnabled: ZDocumentMetaDrawSignatureEnabledSchema.optional(),
|
||||
allowDocumentRejection: ZDocumentMetaAllowDocumentRejectionSchema.optional(),
|
||||
emailId: z.string().nullish(),
|
||||
emailReplyTo: zEmail().nullish(),
|
||||
emailSettings: ZDocumentEmailSettingsSchema.nullish(),
|
||||
|
||||
@@ -63,6 +63,7 @@ export const ZDocumentSchema = LegacyDocumentSchema.pick({
|
||||
uploadSignatureEnabled: true,
|
||||
drawSignatureEnabled: true,
|
||||
allowDictateNextSigner: true,
|
||||
allowDocumentRejection: true,
|
||||
language: true,
|
||||
emailSettings: true,
|
||||
emailId: true,
|
||||
|
||||
@@ -280,6 +280,7 @@ export const ZEditorEnvelopeSchema = EnvelopeSchema.pick({
|
||||
uploadSignatureEnabled: true,
|
||||
drawSignatureEnabled: true,
|
||||
allowDictateNextSigner: true,
|
||||
allowDocumentRejection: true,
|
||||
language: true,
|
||||
emailSettings: true,
|
||||
emailId: true,
|
||||
|
||||
@@ -50,6 +50,7 @@ export const ZEnvelopeSchema = EnvelopeSchema.pick({
|
||||
uploadSignatureEnabled: true,
|
||||
drawSignatureEnabled: true,
|
||||
allowDictateNextSigner: true,
|
||||
allowDocumentRejection: true,
|
||||
language: true,
|
||||
emailSettings: true,
|
||||
emailId: true,
|
||||
|
||||
@@ -13,7 +13,6 @@ export const ZLicenseClaimSchema = z.object({
|
||||
billing: z.boolean().optional(),
|
||||
instanceCscSigning: z.boolean().optional(),
|
||||
cscQesSigning: z.boolean().optional(),
|
||||
instanceTwoFactorEnforcement: z.boolean().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -44,27 +44,15 @@ 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(),
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
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>;
|
||||
@@ -55,6 +55,7 @@ export const ZTemplateSchema = TemplateSchema.pick({
|
||||
uploadSignatureEnabled: true,
|
||||
drawSignatureEnabled: true,
|
||||
allowDictateNextSigner: true,
|
||||
allowDocumentRejection: true,
|
||||
distributionMethod: true,
|
||||
redirectUrl: true,
|
||||
language: true,
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
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>;
|
||||
@@ -70,6 +70,9 @@ export const extractDerivedDocumentMeta = (
|
||||
|
||||
// Reminder settings.
|
||||
reminderSettings: meta.reminderSettings ?? settings.reminderSettings ?? null,
|
||||
|
||||
// Rejection settings.
|
||||
allowDocumentRejection: meta.allowDocumentRejection ?? settings.allowDocumentRejection,
|
||||
} satisfies Omit<DocumentMeta, 'id'>;
|
||||
};
|
||||
|
||||
|
||||
@@ -111,6 +111,7 @@ export const generateDefaultOrganisationSettings = (): Omit<OrganisationGlobalSe
|
||||
documentTimezone: null, // Null means local timezone.
|
||||
documentDateFormat: DEFAULT_DOCUMENT_DATE_FORMAT,
|
||||
delegateDocumentOwnership: false,
|
||||
allowDocumentRejection: true,
|
||||
|
||||
includeSenderDetails: true,
|
||||
includeSigningCertificate: true,
|
||||
@@ -139,9 +140,5 @@ export const generateDefaultOrganisationSettings = (): Omit<OrganisationGlobalSe
|
||||
reminderSettings: DEFAULT_ENVELOPE_REMINDER_SETTINGS,
|
||||
|
||||
aiFeaturesEnabled: false,
|
||||
|
||||
twoFactorRequired: false,
|
||||
twoFactorGracePeriodDays: 7,
|
||||
twoFactorEnforcedFrom: null,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -178,6 +178,7 @@ export const generateDefaultTeamSettings = (): Omit<TeamGlobalSettings, 'id' | '
|
||||
documentTimezone: null,
|
||||
documentDateFormat: null,
|
||||
delegateDocumentOwnership: null,
|
||||
allowDocumentRejection: null,
|
||||
|
||||
includeSenderDetails: null,
|
||||
includeSigningCertificate: null,
|
||||
@@ -209,25 +210,6 @@ 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.
|
||||
*
|
||||
@@ -244,10 +226,6 @@ 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) {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user