mirror of
https://github.com/Gimanh/taskview-community.git
synced 2026-09-11 21:38:56 +00:00
Merge pull request #91 from Gimanh/feat/5047-disable-public-registration
feat: ALLOW_PUBLIC_REGISTRATION
This commit is contained in:
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -69,6 +69,7 @@ export default {
|
||||
invalidCredentials: 'Неверный логин или пароль',
|
||||
loginFailed: 'Не удалось войти. Попробуйте снова.',
|
||||
failedToSendCode: 'Не удалось отправить код. Попробуйте снова.',
|
||||
registrationDisabled: 'Регистрация на этом сервере отключена. Попросите администратора пригласить вас.',
|
||||
failedToResendCode: 'Не удалось отправить код повторно',
|
||||
failedToSendResetLink: 'Не удалось отправить ссылку. Попробуйте снова.',
|
||||
tooManyAttempts: 'Слишком много неверных попыток. Подождите несколько минут и попробуйте снова.',
|
||||
|
||||
@@ -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',
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user