Merge pull request #91 from Gimanh/feat/5047-disable-public-registration

feat: ALLOW_PUBLIC_REGISTRATION
This commit is contained in:
Nikolai Giman
2026-07-18 14:18:16 +02:00
committed by GitHub
14 changed files with 194 additions and 4 deletions
+21
View File
@@ -153,6 +153,11 @@ export default class AuthController {
}
if (!userData) {
if (!(await this.canCreateAccount(req, email))) {
$logger.info(`[AuthController:sendLoginCode] public registration disabled, email not invited`);
return res.status(403).send({ registrationDisabled: true });
}
const password = this.makeidLogin(7),
login = this.makeidLogin(7);
@@ -227,6 +232,11 @@ export default class AuthController {
);
if (!userData) {
if (!(await this.canCreateAccount(req, user.email))) {
$logger.info(`[AuthController:loginByProvider] public registration disabled, email not invited`);
return res.redirect(`${process.env.APP_URL}/login?sso_error=registration-disabled`);
}
const password = this.makeidLogin(7);
const login = this.makeidLogin(7);
@@ -430,6 +440,11 @@ export default class AuthController {
return res.status(400).end();
}
if (!(await this.canCreateAccount(req, email))) {
$logger.info(`[AuthController:registration] public registration disabled, email not invited`);
return res.status(403).send({ registrationDisabled: true });
}
password = hashSync(password, 10);
if (!(await this.comparePasswords(passwordRepeat, password))) {
@@ -671,9 +686,15 @@ export default class AuthController {
password: LoginMethods.isEnabled('password'),
sso: LoginMethods.isEnabled('sso'),
socialProviders: LoginMethods.availableSocialProviders(),
publicRegistration: LoginMethods.publicRegistrationAllowed(),
});
};
private canCreateAccount = async (req: Request, email: string): Promise<boolean> => {
if (LoginMethods.publicRegistrationAllowed()) return true;
return await req.appUser.authManager.repository.isEmailInvited(email);
};
private passwordChangeConfirmationMode(): PasswordChangeConfirmationMode {
return process.env.PASSWORD_CHANGE_CONFIRMATION === 'password' ? 'password' : 'email';
}
+23 -1
View File
@@ -1,4 +1,4 @@
import { eq } from 'drizzle-orm';
import { eq, sql } from 'drizzle-orm';
import { CollaborationUsersSchema, OrganizationMembersSchema, SsoIdentitiesSchema, UsersSchema } from 'taskview-db-schemas';
import { Database } from '../../modules/db';
import { $logger } from '../../modules/logget';
@@ -69,6 +69,28 @@ export default class AuthModel {
}
}
async isEmailInvited(email: string): Promise<boolean> {
const normalized = email.toLowerCase();
try {
const orgMembers = await this.db.dbDrizzle
.select({ email: OrganizationMembersSchema.email })
.from(OrganizationMembersSchema)
.where(sql`lower(${OrganizationMembersSchema.email}) = ${normalized}`)
.limit(1);
if (orgMembers.length > 0) return true;
const collaborators = await this.db.dbDrizzle
.select({ email: CollaborationUsersSchema.email })
.from(CollaborationUsersSchema)
.where(sql`lower(${CollaborationUsersSchema.email}) = ${normalized}`)
.limit(1);
return collaborators.length > 0;
} catch (error: unknown) {
$logger.error(error, '[AuthModel:isEmailInvited] failed to check invitations');
return false;
}
}
async fetchUserById(id: number): Promise<UserDbRecord | false> {
const query = 'SELECT * FROM tv_auth.users WHERE id = $1;';
try {
+14
View File
@@ -15,7 +15,21 @@ export class LoginMethods {
return LoginMethods.enabled().has(method);
}
static publicRegistrationAllowed(): boolean {
return process.env.ALLOW_PUBLIC_REGISTRATION?.trim().toLowerCase() !== 'false';
}
static validateOnStartup(): void {
const registrationRaw = process.env.ALLOW_PUBLIC_REGISTRATION;
if (registrationRaw !== undefined && registrationRaw.trim() !== '') {
const normalized = registrationRaw.trim().toLowerCase();
if (normalized !== 'true' && normalized !== 'false') {
throw new Error(
`ALLOW_PUBLIC_REGISTRATION has unrecognized value "${registrationRaw}". Allowed: true, false`
);
}
}
const raw = process.env.AUTH_LOGIN_METHODS;
if (!raw || !raw.trim()) return;
+1
View File
@@ -240,6 +240,7 @@ The migration container will automatically apply any new database changes on sta
- **Use a reverse proxy** (Nginx, Caddy, Traefik) to terminate SSL and serve everything over HTTPS
- **Update `APP_URL`** in `.env.taskview` and `TASKVIEW_API_URL` on the webapp service to match your production domains
- **Trim the login page** — set `AUTH_LOGIN_METHODS` in `.env.taskview` to offer only the sign-in methods you actually use (e.g. `AUTH_LOGIN_METHODS="password"`). Google/GitHub/Apple buttons are shown only when the provider is configured.
- **Close the instance** — set `ALLOW_PUBLIC_REGISTRATION="false"` so strangers can't create accounts: only emails invited to an organization or project (and users coming through your SSO provider) can sign in and get an account on first login. See [how it works](/docs/configuration/environment-variables#closing-an-instance-how-allow_public_registrationfalse-works).
- **Scale API workers deliberately** — the API runs `PM2_INSTANCES` worker processes (default `2`), and each worker opens its own pool of up to `DB_POOL_MAX` Postgres connections (default `20`). Before raising either value (or using `PM2_INSTANCES=max`), make sure `workers × DB_POOL_MAX` stays below your Postgres `max_connections` (default `100`) — otherwise the API fails with *"sorry, too many clients already"*. The API logs a warning on startup when the budget looks too high.
- **Back up the database** - the `pgdata` volume contains all your data
- **Set `restart: unless-stopped`** on all services so they survive server reboots
@@ -49,11 +49,31 @@ Unlike everything else on this page, this variable is set on the **web app conta
| `JWT_ALG` | No | `HS256` | JWT signing algorithm |
| `AUTH_LOGIN_METHODS` | No | all enabled | Comma-separated list of login methods to offer: `magic-link`, `password`, `sso`, `social`. Disabled methods disappear from the login page and their API endpoints return 403. The API refuses to start if the list contains a typo or disables every method. |
| `PASSWORD_CHANGE_CONFIRMATION` | No | `email` | How account password changes are confirmed: `email` — a confirmation code is sent to the user's email (requires SMTP); `password` — the user confirms with their current password (works without SMTP, recommended for installs without a mail server). |
| `ALLOW_PUBLIC_REGISTRATION` | No | `true` | Set to `false` to close the instance: strangers can no longer create accounts — the registration endpoint returns 403, and magic-link / social sign-in stop auto-creating users. Emails invited to an organization or project can still sign in and get their account created on first login. |
::callout{icon="i-lucide-shield" color="warning"}
Generate a strong JWT secret: `node -e "console.log(require('crypto').randomBytes(64).toString('hex'))"`
::
### Closing an instance: how `ALLOW_PUBLIC_REGISTRATION=false` works
By default anyone who can reach your instance can create an account — through the registration endpoint, or simply by entering an email on the login page (magic-link and social sign-in create the account on first login). Set `ALLOW_PUBLIC_REGISTRATION=false` to close the instance: from that moment accounts are created **by invitation only**.
**Who can still get an account on a closed instance.** TaskView invitations are stored by email, before any account exists. An email is considered invited — and its owner can sign in and get an account created on first login — if it appears in any of these places:
- **Organization members** — added via organization settings (or provisioned through SCIM)
- **Project collaborators** — invited to a project by an existing user
Everyone else is rejected: the registration endpoint returns `403`, magic-link refuses to send a code to an unknown email, and social sign-in redirects back to the login page with an error. Existing accounts are not affected in any way — the flag only controls the *creation* of new ones.
**SSO is not blocked by this flag.** Signing in through a SAML/OIDC provider still provisions accounts, because an identity provider is configured by the administrator and is itself a controlled channel — your IdP decides who gets in.
**The value must be `true` or `false`.** Any other value (a typo like `Flase`, `0`, `no`) stops the server at startup with a clear error instead of silently leaving the instance open.
::callout{icon="i-lucide-users" color="info"}
Note that *any* existing user can invite a collaborator to their project, and an invited email becomes eligible for an account. If your policy is stricter — "only administrators approve new accounts" — restrict who you give accounts to, since every user holds an invitation key to the instance.
::
## SMTP (Email)
Required for password recovery, email confirmation, and invitation notifications. Without SMTP, these features won't work, but everything else functions normally.
@@ -32,6 +32,10 @@ services:
condition: service_completed_successfully
env_file:
- .env.taskview
environment:
# The test suite runs against a closed instance (see registration-flag.test.ts);
# no other test creates accounts through public registration paths.
ALLOW_PUBLIC_REGISTRATION: "false"
extra_hosts:
- "host.docker.internal:host-gateway"
healthcheck:
@@ -0,0 +1,97 @@
import axios from 'axios'
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
import { TvApi } from '@/tv'
import { API_URL, DEFAULT_USER, DEFAULT_PASSWORD, initApi } from './init-api'
// The test stack runs the API with ALLOW_PUBLIC_REGISTRATION=false
// (set in docker/docker-compose.yml), so this suite asserts the
// closed-instance contract: strangers cannot create accounts through
// any public path, invited emails and existing users keep working.
const strangerEmail = `stranger-${Date.now()}@closed.example`
const invitedEmail = `invited-${Date.now()}@closed.example`
let user1Api: TvApi
let testOrgId: number
beforeAll(async () => {
const init = await initApi()
user1Api = init.$tvApi
const org = await user1Api.organizations.create({ name: 'Closed Instance Org' })
testOrgId = org.id
})
afterAll(async () => {
await user1Api.organizations.delete(testOrgId).catch(() => {})
})
describe('ALLOW_PUBLIC_REGISTRATION=false: closed instance', () => {
it('exposes publicRegistration=false in login options', async () => {
const res = await axios.get(`${API_URL}/module/auth/login-options`)
expect(res.data.publicRegistration).toBe(false)
})
it('rejects the registration endpoint for a stranger email', async () => {
const res = await axios.post(
`${API_URL}/module/auth/registration`,
{ email: strangerEmail, password: DEFAULT_PASSWORD, passwordRepeat: DEFAULT_PASSWORD },
{ validateStatus: () => true }
)
expect(res.status).toBe(403)
expect(res.data.registrationDisabled).toBe(true)
})
it('magic-link refuses to create an account for a stranger email', async () => {
const res = await axios.post(
`${API_URL}/module/auth/send-login-code`,
{ email: strangerEmail },
{ validateStatus: () => true }
)
expect(res.status).toBe(403)
expect(res.data.registrationDisabled).toBe(true)
})
it('a stranger email did not get an account (login by password fails)', async () => {
const res = await axios.post(
`${API_URL}/module/auth/login`,
{ login: strangerEmail, password: DEFAULT_PASSWORD },
{ validateStatus: () => true }
)
expect(res.status).toBeGreaterThanOrEqual(400)
})
it('an email invited to an organization can request a login code', async () => {
const member = await user1Api.organizations.addMember({
organizationId: testOrgId,
email: invitedEmail,
role: 'member',
})
expect(member).toBeTruthy()
const res = await axios.post(`${API_URL}/module/auth/send-login-code`, {
email: invitedEmail,
})
expect(res.status).toBe(200)
})
it('invited email got a real account visible to the org owner', async () => {
const members = await user1Api.organizations.fetchMembers(testOrgId)
const invited = members.find((m: { email: string }) => m.email === invitedEmail)
expect(invited).toBeTruthy()
})
it('existing users still sign in normally', async () => {
const res = await axios.post(`${API_URL}/module/auth/login`, {
login: DEFAULT_USER,
password: DEFAULT_PASSWORD,
})
expect(res.data.access).toBeTruthy()
expect(res.data.refresh).toBeTruthy()
})
})
@@ -169,7 +169,7 @@ async function handleSubmit() {
showCodeField.value = true
}
} catch (error: unknown) {
const axiosError = error as { response?: { status?: number } }
const axiosError = error as { response?: { status?: number, data?: { registrationDisabled?: boolean } } }
const status = axiosError.response?.status
if (showCodeField.value) {
let description = t('auth.loginFailed')
@@ -177,9 +177,12 @@ async function handleSubmit() {
else if (status === 400 || status === 403) description = t('auth.invalidCode')
toast.add({ title: t('auth.error'), description, color: 'error' })
} else {
let description = t('auth.failedToSendCode')
if (status === 429) description = t('auth.tooManyAttempts')
else if (status === 403 && axiosError.response?.data?.registrationDisabled) description = t('auth.registrationDisabled')
toast.add({
title: t('auth.error'),
description: status === 429 ? t('auth.tooManyAttempts') : t('auth.failedToSendCode'),
description,
color: 'error',
})
}
@@ -157,6 +157,7 @@ type LoginOptions = {
password: boolean
sso: boolean
socialProviders: string[]
publicRegistration: boolean
}
const currentView = ref<View>('code')
@@ -169,6 +170,7 @@ const loginOptions = reactive<LoginOptions>({
password: true,
sso: true,
socialProviders: ['google', 'github', 'apple'],
publicRegistration: true,
})
onMounted(async () => {
+1
View File
@@ -59,6 +59,7 @@ export default {
invalidCredentials: 'Ungültige Anmeldedaten oder Passwort',
loginFailed: 'Anmeldung fehlgeschlagen. Bitte versuchen Sie es erneut.',
failedToSendCode: 'Code konnte nicht gesendet werden. Bitte versuchen Sie es erneut.',
registrationDisabled: 'Die Registrierung ist auf diesem Server deaktiviert. Bitten Sie einen Administrator um eine Einladung.',
failedToResendCode: 'Code konnte nicht erneut gesendet werden',
failedToSendResetLink: 'Reset-Link konnte nicht gesendet werden. Bitte versuchen Sie es erneut.',
tooManyAttempts: 'Zu viele fehlgeschlagene Versuche. Bitte warten Sie einige Minuten und versuchen Sie es erneut.',
+1
View File
@@ -69,6 +69,7 @@ export default {
invalidCredentials: 'Invalid login or password',
loginFailed: 'Login failed. Please try again.',
failedToSendCode: 'Failed to send code. Please try again.',
registrationDisabled: 'Registration is disabled on this server. Ask an administrator to invite you.',
failedToResendCode: 'Failed to resend code',
failedToSendResetLink: 'Failed to send reset link. Please try again.',
tooManyAttempts: 'Too many failed attempts. Please wait a few minutes before trying again.',
+1
View File
@@ -59,6 +59,7 @@ export default {
invalidCredentials: 'Usuario o contraseña inválidos',
loginFailed: 'Error al iniciar sesión. Inténtalo de nuevo.',
failedToSendCode: 'Error al enviar el código. Inténtalo de nuevo.',
registrationDisabled: 'El registro está deshabilitado en este servidor. Pide a un administrador que te invite.',
failedToResendCode: 'Error al reenviar el código',
failedToSendResetLink: 'Error al enviar el enlace de restablecimiento. Inténtalo de nuevo.',
tooManyAttempts: 'Demasiados intentos fallidos. Espera unos minutos antes de volver a intentarlo.',
+1
View File
@@ -69,6 +69,7 @@ export default {
invalidCredentials: 'Неверный логин или пароль',
loginFailed: 'Не удалось войти. Попробуйте снова.',
failedToSendCode: 'Не удалось отправить код. Попробуйте снова.',
registrationDisabled: 'Регистрация на этом сервере отключена. Попросите администратора пригласить вас.',
failedToResendCode: 'Не удалось отправить код повторно',
failedToSendResetLink: 'Не удалось отправить ссылку. Попробуйте снова.',
tooManyAttempts: 'Слишком много неверных попыток. Подождите несколько минут и попробуйте снова.',
+3 -1
View File
@@ -61,7 +61,9 @@ onMounted(async () => {
if (route.query.sso_error) {
toast.add({
title: t('auth.error'),
description: t('auth.ssoError'),
description: route.query.sso_error === 'registration-disabled'
? t('auth.registrationDisabled')
: t('auth.ssoError'),
color: 'error',
})
}