feat: add email domain restriction for signups (#2266)

Co-authored-by: Lucas Smith <me@lucasjamessmith.me>
This commit is contained in:
Ephraim Duncan
2026-03-14 05:32:34 +00:00
committed by GitHub
parent 3106fd7483
commit 66e357c9b3
16 changed files with 194 additions and 81 deletions
+2
View File
@@ -153,6 +153,8 @@ NEXT_PUBLIC_POSTHOG_KEY=""
NEXT_PUBLIC_FEATURE_BILLING_ENABLED=
# OPTIONAL: Leave blank to allow users to signup through /signup page.
NEXT_PUBLIC_DISABLE_SIGNUP=
# OPTIONAL: Comma-separated list of email domains allowed to sign up (e.g., example.com,acme.org).
NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS=
# OPTIONAL: Set to true to use internal webapp url in browserless requests.
NEXT_PUBLIC_USE_INTERNAL_URL_BROWSERLESS=false
@@ -224,11 +224,31 @@ For detailed certificate setup, see [Signing Certificate](/docs/self-hosting/con
## Feature Flags
| Variable | Description | Default |
| ------------------------------------- | ----------------------------------------------- | ------- |
| `NEXT_PUBLIC_DISABLE_SIGNUP` | Disable public user registration | `false` |
| `NEXT_PUBLIC_POSTHOG_KEY` | PostHog API key for analytics and feature flags | |
| `NEXT_PUBLIC_FEATURE_BILLING_ENABLED` | Enable billing features | `false` |
| Variable | Description | Default |
| ------------------------------------- | ----------------------------------------------------------------------------------- | ------- |
| `NEXT_PUBLIC_DISABLE_SIGNUP` | Disable public user registration entirely | `false` |
| `NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS` | Comma-separated list of email domains allowed to sign up (e.g., `example.com,acme.org`) | |
| `NEXT_PUBLIC_POSTHOG_KEY` | PostHog API key for analytics and feature flags | |
| `NEXT_PUBLIC_FEATURE_BILLING_ENABLED` | Enable billing features | `false` |
### Signup Restrictions
You can control who is allowed to create accounts on your instance using two environment variables:
- **`NEXT_PUBLIC_DISABLE_SIGNUP`**: Set to `true` to block all new signups. Existing users can still sign in. This applies to both email/password and OAuth signups.
- **`NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS`**: Restrict signups to specific email domains. When set, only users whose email address matches one of the listed domains can create an account. Leave empty to allow all domains.
Both restrictions apply to email/password registration and OAuth (Google, Microsoft, OIDC). If a user attempts to sign up via OAuth with a disallowed domain, they are redirected to the sign-in page with an error.
When both variables are set, `NEXT_PUBLIC_DISABLE_SIGNUP` takes precedence. Signups are blocked regardless of the domain list.
```bash
# Allow signups only from specific domains
NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS="example.com,acme.org"
# Or disable signups entirely
NEXT_PUBLIC_DISABLE_SIGNUP="true"
```
---
@@ -328,6 +348,10 @@ NEXT_PRIVATE_SMTP_FROM_ADDRESS="noreply@example.com"
# Signing (certificate must be configured)
NEXT_PRIVATE_SIGNING_PASSPHRASE="your-certificate-password"
# Signup restrictions (optional)
# NEXT_PUBLIC_DISABLE_SIGNUP="true"
# NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS="example.com,acme.org"
```
---
@@ -154,8 +154,9 @@ PORT=3000
# Signing certificate (see Signing Certificate section)
NEXT_PRIVATE_SIGNING_PASSPHRASE=your-certificate-password
# Disable public signups
# Signup restrictions (optional)
NEXT_PUBLIC_DISABLE_SIGNUP=false
# NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS=example.com,acme.org
```
<Callout type="info">Generate secure secrets using: `openssl rand -base64 32`</Callout>
@@ -251,7 +252,8 @@ Navigate to the signup page and create your account. Verify your email address
<Callout type="info">
All accounts created through signup are regular user accounts. Admin access must be granted
directly in the database. Once your accounts are set up, consider disabling public signups by
setting `NEXT_PUBLIC_DISABLE_SIGNUP=true`.
setting `NEXT_PUBLIC_DISABLE_SIGNUP=true`, or restrict signups to specific email domains with
`NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS`.
</Callout>
## Managing Services
@@ -101,6 +101,7 @@ See [Email Configuration](/docs/self-hosting/configuration/email) for other tran
| `NEXT_PRIVATE_SIGNING_LOCAL_FILE_CONTENTS` | Base64-encoded `.p12` certificate (alternative to file path) | - |
| `NEXT_PUBLIC_UPLOAD_TRANSPORT` | Document storage: `database` or `s3` | `database` |
| `NEXT_PUBLIC_DISABLE_SIGNUP` | Disable public signups | `false` |
| `NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS` | Comma-separated list of allowed signup email domains | |
For the complete list, see [Environment Variables](/docs/self-hosting/configuration/environment).
@@ -153,8 +153,9 @@ NEXT_PRIVATE_SMTP_FROM_ADDRESS=noreply@yourdomain.com
| Variable | Description | Default |
| --------------------------------- | ---------------------------------- | ------- |
| `PORT` | Application port | `3000` |
| `NEXT_PUBLIC_DISABLE_SIGNUP` | Disable public signups | `false` |
| `NEXT_PRIVATE_SIGNING_PASSPHRASE` | Passphrase for signing certificate | - |
| `NEXT_PUBLIC_DISABLE_SIGNUP` | Disable public signups | `false` |
| `NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS` | Comma-separated list of allowed signup email domains | |
| `NEXT_PRIVATE_SIGNING_PASSPHRASE` | Passphrase for signing certificate | - |
| `DOCUMENSO_DISABLE_TELEMETRY` | Disable anonymous telemetry | `false` |
</Step>
+56 -63
View File
@@ -54,8 +54,8 @@ export const ZSignUpFormSchema = z
},
);
export const signupErrorMessages: Record<string, MessageDescriptor> = {
SIGNUP_DISABLED: msg`Signups are disabled.`,
export const SIGNUP_ERROR_MESSAGES: Record<string, MessageDescriptor> = {
SIGNUP_DISABLED: msg`Signup is currently disabled or not available for your email domain.`,
[AppErrorCode.ALREADY_EXISTS]: msg`User with this email already exists. Please use a different email address.`,
[AppErrorCode.INVALID_REQUEST]: msg`We were unable to create your account. Please review the information you provided and try again.`,
};
@@ -130,7 +130,8 @@ export const SignUpForm = ({
} catch (err) {
const error = AppError.parseError(err);
const errorMessage = signupErrorMessages[error.code] ?? signupErrorMessages.INVALID_REQUEST;
const errorMessage =
SIGNUP_ERROR_MESSAGES[error.code] ?? SIGNUP_ERROR_MESSAGES.INVALID_REQUEST;
toast({
title: _(msg`An error occurred`),
@@ -196,7 +197,7 @@ export const SignUpForm = ({
return (
<div className={cn('flex justify-center gap-x-12', className)}>
<div className="border-border relative hidden flex-1 overflow-hidden rounded-xl border xl:flex">
<div className="relative hidden flex-1 overflow-hidden rounded-xl border border-border xl:flex">
<div className="absolute -inset-8 -z-[2] backdrop-blur">
<img
src={communityCardsImage}
@@ -205,17 +206,17 @@ export const SignUpForm = ({
/>
</div>
<div className="bg-background/50 absolute -inset-8 -z-[1] backdrop-blur-[2px]" />
<div className="absolute -inset-8 -z-[1] bg-background/50 backdrop-blur-[2px]" />
<div className="relative flex h-full w-full flex-col items-center justify-evenly">
<div className="bg-background rounded-2xl border px-4 py-1 text-sm font-medium">
<div className="rounded-2xl border bg-background px-4 py-1 text-sm font-medium">
<Trans>User profiles are here!</Trans>
</div>
<div className="w-full max-w-md">
<UserProfileTimur
rows={2}
className="bg-background border-border rounded-2xl border shadow-md"
className="rounded-2xl border border-border bg-background shadow-md"
/>
</div>
@@ -223,13 +224,13 @@ export const SignUpForm = ({
</div>
</div>
<div className="border-border dark:bg-background relative z-10 flex min-h-[min(850px,80vh)] w-full max-w-lg flex-col rounded-xl border bg-neutral-100 p-6">
<div className="relative z-10 flex min-h-[min(850px,80vh)] w-full max-w-lg flex-col rounded-xl border border-border bg-neutral-100 p-6 dark:bg-background">
<div className="h-20">
<h1 className="text-xl font-semibold md:text-2xl">
<Trans>Create a new account</Trans>
</h1>
<p className="text-muted-foreground mt-2 text-xs md:text-sm">
<p className="mt-2 text-xs text-muted-foreground md:text-sm">
<Trans>
Create your account and start using state-of-the-art document signing. Open and
beautiful signing is within your grasp.
@@ -323,70 +324,62 @@ export const SignUpForm = ({
/>
{hasSocialAuthEnabled && (
<>
<div className="relative flex items-center justify-center gap-x-4 py-2 text-xs uppercase">
<div className="bg-border h-px flex-1" />
<span className="text-muted-foreground bg-transparent">
<Trans>Or</Trans>
</span>
<div className="bg-border h-px flex-1" />
</div>
</>
<div className="relative flex items-center justify-center gap-x-4 py-2 text-xs uppercase">
<div className="h-px flex-1 bg-border" />
<span className="bg-transparent text-muted-foreground">
<Trans>Or</Trans>
</span>
<div className="h-px flex-1 bg-border" />
</div>
)}
{isGoogleSSOEnabled && (
<>
<Button
type="button"
size="lg"
variant={'outline'}
className="bg-background text-muted-foreground border"
disabled={isSubmitting}
onClick={onSignUpWithGoogleClick}
>
<FcGoogle className="mr-2 h-5 w-5" />
<Trans>Sign Up with Google</Trans>
</Button>
</>
<Button
type="button"
size="lg"
variant={'outline'}
className="border bg-background text-muted-foreground"
disabled={isSubmitting}
onClick={onSignUpWithGoogleClick}
>
<FcGoogle className="mr-2 h-5 w-5" />
<Trans>Sign Up with Google</Trans>
</Button>
)}
{isMicrosoftSSOEnabled && (
<>
<Button
type="button"
size="lg"
variant={'outline'}
className="bg-background text-muted-foreground border"
disabled={isSubmitting}
onClick={onSignUpWithMicrosoftClick}
>
<img
className="mr-2 h-4 w-4"
alt="Microsoft Logo"
src={'/static/microsoft.svg'}
/>
<Trans>Sign Up with Microsoft</Trans>
</Button>
</>
<Button
type="button"
size="lg"
variant={'outline'}
className="border bg-background text-muted-foreground"
disabled={isSubmitting}
onClick={onSignUpWithMicrosoftClick}
>
<img
className="mr-2 h-4 w-4"
alt="Microsoft Logo"
src={'/static/microsoft.svg'}
/>
<Trans>Sign Up with Microsoft</Trans>
</Button>
)}
{isOIDCSSOEnabled && (
<>
<Button
type="button"
size="lg"
variant={'outline'}
className="bg-background text-muted-foreground border"
disabled={isSubmitting}
onClick={onSignUpWithOIDCClick}
>
<FaIdCardClip className="mr-2 h-5 w-5" />
<Trans>Sign Up with OIDC</Trans>
</Button>
</>
<Button
type="button"
size="lg"
variant={'outline'}
className="border bg-background text-muted-foreground"
disabled={isSubmitting}
onClick={onSignUpWithOIDCClick}
>
<FaIdCardClip className="mr-2 h-5 w-5" />
<Trans>Sign Up with OIDC</Trans>
</Button>
)}
<p className="text-muted-foreground mt-4 text-sm">
<p className="mt-4 text-sm text-muted-foreground">
<Trans>
Already have an account?{' '}
<Link to="/signin" className="text-documenso-700 duration-200 hover:opacity-70">
@@ -406,7 +399,7 @@ export const SignUpForm = ({
</Button>
</form>
</Form>
<p className="text-muted-foreground mt-6 text-xs">
<p className="mt-6 text-xs text-muted-foreground">
<Trans>
By proceeding, you agree to our{' '}
<Link
@@ -23,7 +23,7 @@ import { Input } from '@documenso/ui/primitives/input';
import { PasswordInput } from '@documenso/ui/primitives/password-input';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { signupErrorMessages } from '~/components/forms/signup';
import { SIGNUP_ERROR_MESSAGES } from '~/components/forms/signup';
export type ClaimAccountProps = {
defaultName: string;
@@ -90,7 +90,8 @@ export const ClaimAccount = ({ defaultName, defaultEmail }: ClaimAccountProps) =
} catch (err) {
const error = AppError.parseError(err);
const errorMessage = signupErrorMessages[error.code] ?? signupErrorMessages.INVALID_REQUEST;
const errorMessage =
SIGNUP_ERROR_MESSAGES[error.code] ?? SIGNUP_ERROR_MESSAGES.INVALID_REQUEST;
toast({
title: _(msg`An error occurred`),
@@ -1,7 +1,8 @@
import { useEffect, useState } from 'react';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { Link, redirect } from 'react-router';
import { Link, redirect, useSearchParams } from 'react-router';
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
import {
@@ -12,8 +13,10 @@ import {
} from '@documenso/lib/constants/auth';
import { env } from '@documenso/lib/utils/env';
import { isValidReturnTo, normalizeReturnTo } from '@documenso/lib/utils/is-valid-return-to';
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
import { SignInForm } from '~/components/forms/signin';
import { SIGNUP_ERROR_MESSAGES } from '~/components/forms/signup';
import { appMetaTags } from '~/utils/meta';
import type { Route } from './+types/signin';
@@ -57,8 +60,14 @@ export default function SignIn({ loaderData }: Route.ComponentProps) {
returnTo,
} = loaderData;
const { _ } = useLingui();
const [searchParams] = useSearchParams();
const [isEmbeddedRedirect, setIsEmbeddedRedirect] = useState(false);
const errorParam = searchParams.get('error');
const signupError = errorParam ? SIGNUP_ERROR_MESSAGES[errorParam] : undefined;
useEffect(() => {
const hash = window.location.hash.slice(1);
@@ -69,12 +78,18 @@ export default function SignIn({ loaderData }: Route.ComponentProps) {
return (
<div className="w-screen max-w-lg px-4">
<div className="border-border dark:bg-background z-10 rounded-xl border bg-neutral-100 p-6">
<div className="z-10 rounded-xl border border-border bg-neutral-100 p-6 dark:bg-background">
{signupError && (
<Alert variant="destructive" className="mb-4">
<AlertDescription>{_(signupError)}</AlertDescription>
</Alert>
)}
<h1 className="text-2xl font-semibold">
<Trans>Sign in to your account</Trans>
</h1>
<p className="text-muted-foreground mt-2 text-sm">
<p className="mt-2 text-sm text-muted-foreground">
<Trans>Welcome back, we are lucky to have you.</Trans>
</p>
<hr className="-mx-6 my-4" />
@@ -88,7 +103,7 @@ export default function SignIn({ loaderData }: Route.ComponentProps) {
/>
{!isEmbeddedRedirect && env('NEXT_PUBLIC_DISABLE_SIGNUP') !== 'true' && (
<p className="text-muted-foreground mt-6 text-center text-sm">
<p className="mt-6 text-center text-sm text-muted-foreground">
<Trans>
Don't have an account?{' '}
<Link
+1
View File
@@ -254,3 +254,4 @@ Here's a markdown table documenting all the provided environment variables:
| `NEXT_PUBLIC_DOCUMENT_SIZE_UPLOAD_LIMIT` | The maximum document upload limit displayed to the user (in MB). |
| `NEXT_PUBLIC_POSTHOG_KEY` | The optional PostHog key for analytics and feature flags. |
| `NEXT_PUBLIC_DISABLE_SIGNUP` | Whether to disable user signups through the /signup page. |
| `NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS` | Comma-separated list of email domains allowed to sign up (e.g., `example.com,acme.org`). |
+1
View File
@@ -59,6 +59,7 @@ services:
- NEXT_PUBLIC_DOCUMENT_SIZE_UPLOAD_LIMIT=${NEXT_PUBLIC_DOCUMENT_SIZE_UPLOAD_LIMIT}
- NEXT_PUBLIC_POSTHOG_KEY=${NEXT_PUBLIC_POSTHOG_KEY}
- NEXT_PUBLIC_DISABLE_SIGNUP=${NEXT_PUBLIC_DISABLE_SIGNUP}
- NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS=${NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS}
- NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH=${NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH:-/opt/documenso/cert.p12}
- NEXT_PRIVATE_SIGNING_PASSPHRASE=${NEXT_PRIVATE_SIGNING_PASSPHRASE}
- NEXT_PUBLIC_USE_INTERNAL_URL_BROWSERLESS=${NEXT_PUBLIC_USE_INTERNAL_URL_BROWSERLESS}
@@ -17,6 +17,7 @@ export const AuthenticationErrorCode = {
// TwoFactorMissingSecret: 'TWO_FACTOR_MISSING_SECRET',
// TwoFactorMissingCredentials: 'TWO_FACTOR_MISSING_CREDENTIALS',
InvalidTwoFactorCode: 'INVALID_TWO_FACTOR_CODE',
SignupDisabled: 'SIGNUP_DISABLED',
// IncorrectTwoFactorBackupCode: 'INCORRECT_TWO_FACTOR_BACKUP_CODE',
// IncorrectIdentityProvider: 'INCORRECT_IDENTITY_PROVIDER',
// IncorrectPassword: 'INCORRECT_PASSWORD',
@@ -3,8 +3,11 @@ import { OAuth2Client, decodeIdToken } from 'arctic';
import type { Context } from 'hono';
import { deleteCookie } from 'hono/cookie';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { isEmailDomainAllowedForSignup } from '@documenso/lib/constants/auth';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { onCreateUserHook } from '@documenso/lib/server-only/user/create-user';
import { env } from '@documenso/lib/utils/env';
import { deletedServiceAccountEmail } from '@documenso/lib/server-only/user/service-accounts/deleted-account';
import { legacyServiceAccountEmail } from '@documenso/lib/server-only/user/service-accounts/legacy-service-account';
import { isValidReturnTo, normalizeReturnTo } from '@documenso/lib/utils/is-valid-return-to';
@@ -114,6 +117,24 @@ export const handleOAuthCallbackUrl = async (options: HandleOAuthCallbackUrlOpti
return c.redirect(redirectPath, 302);
}
// Check if signups are disabled.
if (env('NEXT_PUBLIC_DISABLE_SIGNUP') === 'true') {
const errorUrl = new URL('/signin', NEXT_PUBLIC_WEBAPP_URL());
errorUrl.searchParams.set('error', AuthenticationErrorCode.SignupDisabled);
return c.redirect(errorUrl.toString(), 302);
}
// Check domain restriction for new SSO users.
if (!isEmailDomainAllowedForSignup(email)) {
const errorUrl = new URL('/signin', NEXT_PUBLIC_WEBAPP_URL());
errorUrl.searchParams.set('error', AuthenticationErrorCode.SignupDisabled);
return c.redirect(errorUrl.toString(), 302);
}
// Handle new user.
const createdUser = await prisma.$transaction(async (tx) => {
const user = await tx.user.create({
+14 -3
View File
@@ -6,6 +6,7 @@ import { HTTPException } from 'hono/http-exception';
import { DateTime } from 'luxon';
import { z } from 'zod';
import { isEmailDomainAllowedForSignup } from '@documenso/lib/constants/auth';
import { EMAIL_VERIFICATION_STATE } from '@documenso/lib/constants/email';
import { AppError } from '@documenso/lib/errors/app-error';
import { jobsClient } from '@documenso/lib/jobs/client';
@@ -122,7 +123,11 @@ export const emailPasswordRoute = new Hono<HonoAuthContext>()
const is2faEnabled = isTwoFactorAuthenticationEnabled({ user });
if (is2faEnabled) {
const isValid = await validateTwoFactorAuthentication({ backupCode, totpCode, user });
const isValid = await validateTwoFactorAuthentication({
backupCode,
totpCode,
user,
});
if (!isValid) {
await prisma.userSecurityAuditLog.create({
@@ -178,8 +183,8 @@ export const emailPasswordRoute = new Hono<HonoAuthContext>()
const requestMetadata = c.get('requestMetadata');
if (env('NEXT_PUBLIC_DISABLE_SIGNUP') === 'true') {
throw new AppError('SIGNUP_DISABLED', {
message: 'Signups are disabled.',
throw new AppError(AuthenticationErrorCode.SignupDisabled, {
statusCode: 400,
});
}
@@ -197,6 +202,12 @@ export const emailPasswordRoute = new Hono<HonoAuthContext>()
});
}
if (!isEmailDomainAllowedForSignup(email)) {
throw new AppError(AuthenticationErrorCode.SignupDisabled, {
statusCode: 400,
});
}
const user = await createUser({ name, email, password, signature }).catch((err) => {
console.error(err);
throw err;
+37
View File
@@ -69,3 +69,40 @@ export const getCookieDomain = () => {
return url.hostname;
};
/**
* Get allowed signup domains from env var.
* Returns empty array if not set (meaning all domains allowed).
*/
export const getAllowedSignupDomains = (): string[] => {
const domains = env('NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS');
if (!domains) {
return [];
}
return domains
.split(',')
.map((d) => d.trim().toLowerCase())
.filter(Boolean);
};
/**
* Check if email domain is allowed for signup.
* Returns true if no domain restriction is configured.
*/
export const isEmailDomainAllowedForSignup = (email: string): boolean => {
const allowedDomains = getAllowedSignupDomains();
if (allowedDomains.length === 0) {
return true;
}
const emailDomain = email.toLowerCase().split('@').pop();
if (!emailDomain) {
return false;
}
return allowedDomains.includes(emailDomain);
};
+1
View File
@@ -74,6 +74,7 @@ declare namespace NodeJS {
NEXT_PRIVATE_SMTP_FROM_ADDRESS?: string;
NEXT_PUBLIC_DISABLE_SIGNUP?: string;
NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS?: string;
NEXT_PRIVATE_BROWSERLESS_URL?: string;
+1
View File
@@ -48,6 +48,7 @@
"NEXT_PUBLIC_POSTHOG_KEY",
"NEXT_PUBLIC_FEATURE_BILLING_ENABLED",
"NEXT_PUBLIC_DISABLE_SIGNUP",
"NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS",
"NEXT_PRIVATE_PLAIN_API_KEY",
"NEXT_PUBLIC_DOCUMENT_SIZE_UPLOAD_LIMIT",
"NEXT_PRIVATE_DOCUMENSO_LICENSE_KEY",