diff --git a/api/src/tv-modules/auth/AuthController.ts b/api/src/tv-modules/auth/AuthController.ts index 389ba3b..5979b24 100644 --- a/api/src/tv-modules/auth/AuthController.ts +++ b/api/src/tv-modules/auth/AuthController.ts @@ -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 => { + 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'; } diff --git a/api/src/tv-modules/auth/AuthModel.ts b/api/src/tv-modules/auth/AuthModel.ts index 21043f1..4707089 100644 --- a/api/src/tv-modules/auth/AuthModel.ts +++ b/api/src/tv-modules/auth/AuthModel.ts @@ -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 { + 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 { const query = 'SELECT * FROM tv_auth.users WHERE id = $1;'; try { diff --git a/api/src/tv-modules/auth/LoginMethods.ts b/api/src/tv-modules/auth/LoginMethods.ts index ef42405..17c0a13 100644 --- a/api/src/tv-modules/auth/LoginMethods.ts +++ b/api/src/tv-modules/auth/LoginMethods.ts @@ -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; diff --git a/docs/1.getting-started/2.installation.md b/docs/1.getting-started/2.installation.md index b5a37d9..dcb8c07 100644 --- a/docs/1.getting-started/2.installation.md +++ b/docs/1.getting-started/2.installation.md @@ -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 diff --git a/docs/4.configuration/1.environment-variables.md b/docs/4.configuration/1.environment-variables.md index 5ca1c85..832f638 100644 --- a/docs/4.configuration/1.environment-variables.md +++ b/docs/4.configuration/1.environment-variables.md @@ -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. diff --git a/taskview-packages/taskview-api/src/api/__tests__/docker/docker-compose.yml b/taskview-packages/taskview-api/src/api/__tests__/docker/docker-compose.yml index e5a6b65..98ed3b6 100644 --- a/taskview-packages/taskview-api/src/api/__tests__/docker/docker-compose.yml +++ b/taskview-packages/taskview-api/src/api/__tests__/docker/docker-compose.yml @@ -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: diff --git a/taskview-packages/taskview-api/src/api/__tests__/registration-flag.test.ts b/taskview-packages/taskview-api/src/api/__tests__/registration-flag.test.ts new file mode 100644 index 0000000..0edad3e --- /dev/null +++ b/taskview-packages/taskview-api/src/api/__tests__/registration-flag.test.ts @@ -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() + }) +}) diff --git a/web/src/components/features/auth/LoginByCode.vue b/web/src/components/features/auth/LoginByCode.vue index c462240..24ccc65 100644 --- a/web/src/components/features/auth/LoginByCode.vue +++ b/web/src/components/features/auth/LoginByCode.vue @@ -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', }) } diff --git a/web/src/components/features/auth/LoginForm.vue b/web/src/components/features/auth/LoginForm.vue index a96bcef..8a2b530 100644 --- a/web/src/components/features/auth/LoginForm.vue +++ b/web/src/components/features/auth/LoginForm.vue @@ -157,6 +157,7 @@ type LoginOptions = { password: boolean sso: boolean socialProviders: string[] + publicRegistration: boolean } const currentView = ref('code') @@ -169,6 +170,7 @@ const loginOptions = reactive({ password: true, sso: true, socialProviders: ['google', 'github', 'apple'], + publicRegistration: true, }) onMounted(async () => { diff --git a/web/src/locales/de.ts b/web/src/locales/de.ts index 8bb1325..268868f 100644 --- a/web/src/locales/de.ts +++ b/web/src/locales/de.ts @@ -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.', diff --git a/web/src/locales/en.ts b/web/src/locales/en.ts index 882a1b8..4e6babe 100644 --- a/web/src/locales/en.ts +++ b/web/src/locales/en.ts @@ -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.', diff --git a/web/src/locales/es.ts b/web/src/locales/es.ts index 8056c45..a92f58b 100644 --- a/web/src/locales/es.ts +++ b/web/src/locales/es.ts @@ -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.', diff --git a/web/src/locales/ru.ts b/web/src/locales/ru.ts index cddd8bd..8bea9d2 100644 --- a/web/src/locales/ru.ts +++ b/web/src/locales/ru.ts @@ -69,6 +69,7 @@ export default { invalidCredentials: 'Неверный логин или пароль', loginFailed: 'Не удалось войти. Попробуйте снова.', failedToSendCode: 'Не удалось отправить код. Попробуйте снова.', + registrationDisabled: 'Регистрация на этом сервере отключена. Попросите администратора пригласить вас.', failedToResendCode: 'Не удалось отправить код повторно', failedToSendResetLink: 'Не удалось отправить ссылку. Попробуйте снова.', tooManyAttempts: 'Слишком много неверных попыток. Подождите несколько минут и попробуйте снова.', diff --git a/web/src/pages/login.vue b/web/src/pages/login.vue index ff4b080..11dde29 100644 --- a/web/src/pages/login.vue +++ b/web/src/pages/login.vue @@ -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', }) }