Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add public registration #22125

Merged
merged 76 commits into from May 7, 2024
Merged
Show file tree
Hide file tree
Changes from 32 commits
Commits
Show all changes
76 commits
Select commit Hold shift + click to select a range
45eefff
WIP: add new register dummy-route
DanielBiegler Apr 8, 2024
de3b6c7
fix notice on register route
DanielBiegler Apr 9, 2024
7cb09e2
WIP register form
DanielBiegler Apr 9, 2024
3ca7722
WIP: registering ui and controller for testing
DanielBiegler Apr 10, 2024
d8f1e20
fix lint ordering problem
DanielBiegler Apr 10, 2024
5d0861e
wip: users service
DanielBiegler Apr 10, 2024
3c1099d
add migration, initial style for fields in settings
DanielBiegler Apr 22, 2024
be9bdbe
redo how emails will be filtered
DanielBiegler Apr 23, 2024
266d114
WIP add filter in the register handler
DanielBiegler Apr 23, 2024
38bbd9b
conditionally render register link depending on settings
DanielBiegler Apr 23, 2024
c4a9575
Merge remote-tracking branch 'origin/main' into fix-21981-public-regi…
DanielBiegler Apr 25, 2024
950a8ac
WIP: add email validation
DanielBiegler Apr 25, 2024
4e54498
wip add email sending
DanielBiegler Apr 26, 2024
130cc9c
make clicking the email link work
DanielBiegler Apr 29, 2024
2a3ef64
rm console log
DanielBiegler Apr 29, 2024
d7167d4
update controller
DanielBiegler Apr 29, 2024
f9ebb9e
dont send emails for existing emails
DanielBiegler Apr 30, 2024
6c2e6e2
add translation
DanielBiegler Apr 30, 2024
c9c19f6
only show register link when unauthenticated
DanielBiegler Apr 30, 2024
9eee9ea
add different redirects
DanielBiegler Apr 30, 2024
316bd7c
only allow selecting non-admin roles
DanielBiegler Apr 30, 2024
17e41e6
redirect to users page
DanielBiegler Apr 30, 2024
afcf208
update translation
DanielBiegler Apr 30, 2024
2f7c628
move logic from controller to usersservice
DanielBiegler Apr 30, 2024
2e6f166
rm remnant of logic from controller
DanielBiegler Apr 30, 2024
c9291b1
add stall time to registration
DanielBiegler Apr 30, 2024
f2e327d
update translation
DanielBiegler Apr 30, 2024
45ab86b
rm comments
DanielBiegler Apr 30, 2024
4eb6a7d
rm unused var
DanielBiegler Apr 30, 2024
60403ba
Merge remote-tracking branch 'origin/main' into fix-21981-public-regi…
DanielBiegler Apr 30, 2024
3e7fb98
add changeset
DanielBiegler Apr 30, 2024
92c8280
update translation for success
DanielBiegler Apr 30, 2024
421cc56
remove sso related stuff from registration
DanielBiegler Apr 30, 2024
d69c13d
also allow setting first and last name
DanielBiegler May 2, 2024
9ec9ab6
update error check
DanielBiegler May 3, 2024
4436d07
add @directus/errors to app
DanielBiegler May 3, 2024
860e07f
replace error strings with enum
DanielBiegler May 3, 2024
805d289
rename to public_registration
DanielBiegler May 3, 2024
3f88ad0
rename to public_registration_verify_email
DanielBiegler May 3, 2024
17cc82a
add notes to fields
DanielBiegler May 3, 2024
7d93c54
add types package to changeset
DanielBiegler May 3, 2024
0d2b2da
dont stall if no work is being done
DanielBiegler May 3, 2024
de01b3e
allow null-role and resending of reg. email
DanielBiegler May 3, 2024
2f780f5
add public registration env vars, rm RATE_LIMITER_GLOBAL_STORE
DanielBiegler May 6, 2024
5f93cf0
use ratelimiter for registration, use stall time env var
DanielBiegler May 6, 2024
8d69531
add registration limiter docs, rm global store variable from docs
DanielBiegler May 6, 2024
d6cff7d
update changeset
DanielBiegler May 6, 2024
967c218
Merge remote-tracking branch 'origin/main' into fix-21981-public-regi…
DanielBiegler May 6, 2024
452d8bb
add ignore-notice
DanielBiegler May 6, 2024
854dd1c
use and document new `EMAIL_VERIFICATION_TOKEN_TTL`, also doc `REGIST…
DanielBiegler May 7, 2024
6e738ac
change variable name
DanielBiegler May 7, 2024
7b1991f
apply variable rename to usage
DanielBiegler May 7, 2024
cafac8d
change backticks to single quote
DanielBiegler May 7, 2024
5ffa0f3
inline variables
DanielBiegler May 7, 2024
2a59022
Merge remote-tracking branch 'refs/remotes/origin/fix-21981-public-re…
DanielBiegler May 7, 2024
0834b19
add fields to server info, update types
DanielBiegler May 7, 2024
c1fc19f
tiny wording tweak of registration mail
br41nslug May 7, 2024
9c9cf02
add new user status 'unverified' and check for it
DanielBiegler May 7, 2024
0354280
Merge remote-tracking branch 'refs/remotes/origin/fix-21981-public-re…
DanielBiegler May 7, 2024
c62f89b
add unverified status translation
DanielBiegler May 7, 2024
9ee172c
decouple email verification and validation
DanielBiegler May 7, 2024
6287505
enable register rate limiter by default and up its config
DanielBiegler May 7, 2024
5792248
add autocomplete=new-password on the registration form
br41nslug May 7, 2024
65a3c94
added sdk functions
br41nslug May 7, 2024
56c5fff
add gql query for new fields
DanielBiegler May 7, 2024
c7f7fc7
Merge remote-tracking branch 'refs/remotes/origin/fix-21981-public-re…
DanielBiegler May 7, 2024
b72546b
added register api reference
br41nslug May 7, 2024
340edf0
Merge branch 'fix-21981-public-registration' of https://github.com/di…
br41nslug May 7, 2024
9a60c18
updated verify sdk function name
br41nslug May 7, 2024
a4429f6
added reference block for email verify endpoint
br41nslug May 7, 2024
0490ae2
updated reference examples
br41nslug May 7, 2024
b0d5ccb
WIP: add gql resolvers
DanielBiegler May 7, 2024
4d87b46
add ratelimiter to mutation
DanielBiegler May 7, 2024
7caed4f
remove ratelimiter registration point+duration info
DanielBiegler May 7, 2024
8624940
rm points and duration from gql
DanielBiegler May 7, 2024
3966509
Update docs/reference/system/users.md
br41nslug May 7, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/dry-otters-attend.md
@@ -0,0 +1,8 @@
---

Check warning on line 1 in .changeset/dry-otters-attend.md

View workflow job for this annotation

GitHub Actions / Lint

File ignored by default.
'@directus/system-data': patch
licitdev marked this conversation as resolved.
Show resolved Hide resolved
'@directus/api': patch
'@directus/app': patch
'@directus/sdk': patch
---

Added API and UI for public registration
38 changes: 38 additions & 0 deletions api/src/controllers/users.ts
Expand Up @@ -501,4 +501,42 @@ router.post(
respond,
);

const registerSchema = Joi.object<{ email: string; password: string }>({
email: Joi.string().email().required(),
password: Joi.string().required(),
});

router.post(
'/register',
br41nslug marked this conversation as resolved.
Show resolved Hide resolved
asyncHandler(async (req, _res, next) => {
const { error, value } = registerSchema.validate(req.body);
if (error) throw new InvalidPayloadError({ reason: error.message });

const usersService = new UsersService({ accountability: null, schema: req.schema });
await usersService.registerUser(value.email, value.password);

return next();
}),
respond,
);

const verifyRegistrationSchema = Joi.string();

router.get(
'/register/verify-email',
br41nslug marked this conversation as resolved.
Show resolved Hide resolved
asyncHandler(async (req, res, _next) => {
const { error, value } = verifyRegistrationSchema.validate(req.query['token']);

if (error) {
return res.redirect('/admin/login');
}

const service = new UsersService({ accountability: null, schema: req.schema });
const id = await service.verifyRegistration(value);

return res.redirect(`/admin/users/${id}`);
br41nslug marked this conversation as resolved.
Show resolved Hide resolved
}),
respond,
);

export default router;
31 changes: 31 additions & 0 deletions api/src/database/migrations/20240422A-public-registration.ts
@@ -0,0 +1,31 @@
import type { Knex } from 'knex';

// To avoid typos
DanielBiegler marked this conversation as resolved.
Show resolved Hide resolved
const TABLE_ROLES = 'directus_roles';
const COLUMN_ROLES_ID = `${TABLE_ROLES}.id`;
const TABLE_SETTINGS = 'directus_settings';
const NEW_COLUMN_IS_REGISTRATION_ENABLED = 'is_public_registration_enabled';
const NEW_COLUMN_ROLE = 'public_registration_role';
const NEW_COLUMN_IS_EMAIL_VALIDATION_ENABLED = 'is_public_registration_email_validation_enabled';
const NEW_COLUMN_EMAIL_FILTER = 'public_registration_email_filter';

export async function up(knex: Knex): Promise<void> {
await knex.schema.alterTable(TABLE_SETTINGS, (table) => {
table.boolean(NEW_COLUMN_IS_REGISTRATION_ENABLED).notNullable().defaultTo(false);
table.boolean(NEW_COLUMN_IS_EMAIL_VALIDATION_ENABLED).notNullable().defaultTo(true);
table.uuid(NEW_COLUMN_ROLE).nullable();
table.foreign(NEW_COLUMN_ROLE).references(COLUMN_ROLES_ID).onDelete('SET NULL');
table.json(NEW_COLUMN_EMAIL_FILTER).nullable();
});
}

export async function down(knex: Knex): Promise<void> {
await knex.schema.alterTable(TABLE_SETTINGS, (table) => {
table.dropColumns(
NEW_COLUMN_IS_REGISTRATION_ENABLED,
NEW_COLUMN_IS_EMAIL_VALIDATION_ENABLED,
NEW_COLUMN_ROLE,
NEW_COLUMN_EMAIL_FILTER,
);
});
}
36 changes: 36 additions & 0 deletions api/src/services/mail/templates/user-registration.liquid
@@ -0,0 +1,36 @@
{% layout 'base' %} {% block content %}

<h1>Verify your email address</h1>

<p style='padding-bottom: 30px'>
Thanks for registering with us at <i>{{ projectName }}</i>.
br41nslug marked this conversation as resolved.
Show resolved Hide resolved
To complete your registration you need to verify your email address by opening the following verification-link.
</p>
br41nslug marked this conversation as resolved.
Show resolved Hide resolved

<a
class='button'
rel='noopener'
target='_blank'
href='{{url}}'
style='
font-size: 16px;
font-weight: 600;
color: #ffffff;
text-decoration: none;
display: inline-block;
padding: 11px 24px;
border-radius: 8px;
background: #171717;
border: 1px solid #ffffff;
'
>
<!--[if mso]>
<i style="letter-spacing: 25px; mso-font-width: -100%; mso-text-raise: 30pt"
>&nbsp;</i
>
<![endif]-->
<span style='mso-text-raise: 15pt'>Verify email</span>
<!--[if mso]> <i style="letter-spacing: 25px; mso-font-width: -100%">&nbsp;</i> <![endif]-->
</a>

{% endblock %}
1 change: 1 addition & 0 deletions api/src/services/server.ts
Expand Up @@ -54,6 +54,7 @@ export class ServerService {
'public_favicon',
'public_note',
'custom_css',
'is_public_registration_enabled',
],
});

Expand Down
118 changes: 115 additions & 3 deletions api/src/services/users.ts
@@ -1,7 +1,14 @@
import { useEnv } from '@directus/env';
import { ForbiddenError, InvalidPayloadError, RecordNotUniqueError, UnprocessableContentError } from '@directus/errors';
import type { Item, PrimaryKey, Query } from '@directus/types';
import { getSimpleHash, toArray } from '@directus/utils';
import {
ContainsNullValuesError,
ForbiddenError,
InvalidPayloadError,
RecordNotUniqueError,
UnprocessableContentError,
isDirectusError,
} from '@directus/errors';
import type { Item, PrimaryKey, Query, User } from '@directus/types';
import { getSimpleHash, toArray, validatePayload } from '@directus/utils';
import { FailedValidationError, joiValidationErrorItemToErrorExtensions } from '@directus/validation';
import Joi from 'joi';
import jwt from 'jsonwebtoken';
Expand Down Expand Up @@ -451,6 +458,111 @@ export class UsersService extends ItemsService {
await service.updateOne(user.id, { password, status: 'active' });
}

async registerUser(email: string, password: string) {
const STALL_TIME = 750;
const timeStart = performance.now();
const serviceOptions: AbstractServiceOptions = { accountability: this.accountability, schema: this.schema };
const settingsService = new SettingsService(serviceOptions);

const settings = await settingsService.readSingleton({
fields: [
'is_public_registration_enabled',
'is_public_registration_email_validation_enabled',
'public_registration_role',
'public_registration_email_filter',
],
});

if (settings?.['is_public_registration_enabled'] == false) {
await stall(STALL_TIME, timeStart);
throw new ForbiddenError();
}

const publicRegistrationRole = settings?.['public_registration_role'];

if (!publicRegistrationRole) {
await stall(STALL_TIME, timeStart);
throw new ContainsNullValuesError({ collection: 'directus_settings', field: 'public_registration_role' });
}

const hasEmailValidation = settings?.['is_public_registration_email_validation_enabled'];

const partialUser: Partial<User> = {
email,
password,
role: publicRegistrationRole,
status: hasEmailValidation ? 'draft' : 'active', // TODO: Do we want to have a dedicated "unverified" status?
br41nslug marked this conversation as resolved.
Show resolved Hide resolved
};

const emailFilter = settings?.['public_registration_email_filter'];

if (hasEmailValidation && emailFilter && validatePayload(emailFilter, { email }).length !== 0) {
await stall(STALL_TIME, timeStart);
throw new FailedValidationError({ field: 'email', type: 'email' });
}

try {
await this.createOne(partialUser);
} catch (error: unknown) {
// To avoid giving attackers infos about registered emails we dont fail for violated unique constraints
if (isDirectusError(error) && error.code === 'RECORD_NOT_UNIQUE') {
DanielBiegler marked this conversation as resolved.
Show resolved Hide resolved
await stall(STALL_TIME, timeStart);
return;
}

await stall(STALL_TIME, timeStart);
throw error;
}
DanielBiegler marked this conversation as resolved.
Show resolved Hide resolved

if (hasEmailValidation) {
const mailService = new MailService(serviceOptions);
const payload = { email, scope: 'pending-registration' };
const token = jwt.sign(payload, env['SECRET'] as string, { expiresIn: '7d', issuer: 'directus' });
DanielBiegler marked this conversation as resolved.
Show resolved Hide resolved

const verificationURL = new Url(env['PUBLIC_URL'] as string)
.addPath('users', 'register', 'verify-email')
.setQuery('token', token);

mailService
.send({
to: email,
subject: 'Verify your email address', // TODO: translate after theres support for internationalized emails
template: {
name: 'user-registration',
data: {
url: verificationURL.toString(),
email,
},
},
})
.catch((error) => {
logger.error(error, 'Could not send email verification mail');
});
}

await stall(STALL_TIME, timeStart);
}

async verifyRegistration(token: string): Promise<string> {
const { email, scope } = verifyJWT(token, env['SECRET'] as string) as {
email: string;
scope: string;
};

if (scope !== 'pending-registration') throw new ForbiddenError();

const user = await this.getUserByEmail(email);

// TODO: might want to have their own status?
if (user?.status !== 'draft') {
throw new InvalidPayloadError({ reason: `Invalid verification code` });
DanielBiegler marked this conversation as resolved.
Show resolved Hide resolved
}

await this.updateOne(user.id, { status: 'active' });

return user.id;
}

async requestPasswordReset(email: string, url: string | null, subject?: string | null): Promise<void> {
const STALL_TIME = 500;
const timeStart = performance.now();
Expand Down
12 changes: 12 additions & 0 deletions app/src/lang/translations/en-US.yaml
Expand Up @@ -1364,6 +1364,11 @@ fields:
custom_aspect_ratios: Custom Aspect Ratios
theme_light_overrides: Light Theme Customization
theme_dark_overrides: Dark Theme Customization
public_registration: Public registration
is_public_registration_enabled: Public registration
DanielBiegler marked this conversation as resolved.
Show resolved Hide resolved
public_registration_role: Role
public_registration_email_filter: Email Filter
DanielBiegler marked this conversation as resolved.
Show resolved Hide resolved
is_public_registration_email_validation_enabled: Send verification email
DanielBiegler marked this conversation as resolved.
Show resolved Hide resolved
directus_shares:
name: Name
role: Role
Expand Down Expand Up @@ -1631,6 +1636,13 @@ select_field_type: Select a field type
select_interface: Select an interface
select_display: Select a display
settings: Settings
register: Register
registration_successful_headline: Success!
registration_successful_note: |
Please note that before you can sign in, you may need to verify your email address by clicking on the verification link sent to you.
dont_have_an_account: Don't have an account?
already_have_an_account: Already have an account?
br41nslug marked this conversation as resolved.
Show resolved Hide resolved
sign_up_now: Sign up now
DanielBiegler marked this conversation as resolved.
Show resolved Hide resolved
sign_in: Sign In
sign_out: Sign Out
sign_out_confirm: Are you sure you want to sign out?
Expand Down
9 changes: 9 additions & 0 deletions app/src/router.ts
Expand Up @@ -4,6 +4,7 @@ import AcceptInviteRoute from '@/routes/accept-invite.vue';
import LoginRoute from '@/routes/login/login.vue';
import LogoutRoute from '@/routes/logout.vue';
import PrivateNotFoundRoute from '@/routes/private-not-found.vue';
import RegisterRoute from '@/routes/register/register.vue';
import ResetPasswordRoute from '@/routes/reset-password/reset-password.vue';
import ShareRoute from '@/routes/shared/shared.vue';
import TFASetup from '@/routes/tfa-setup.vue';
Expand Down Expand Up @@ -38,6 +39,14 @@ export const defaultRoutes: RouteRecordRaw[] = [
public: true,
},
},
{
name: 'register',
path: '/register',
component: RegisterRoute,
meta: {
public: true,
},
},
{
name: 'accept-invite',
path: '/accept-invite',
Expand Down
42 changes: 32 additions & 10 deletions app/src/routes/login/login.vue
Expand Up @@ -2,13 +2,13 @@
import { DEFAULT_AUTH_DRIVER, DEFAULT_AUTH_PROVIDER } from '@/constants';
import { useServerStore } from '@/stores/server';
import { useAppStore } from '@directus/stores';
import { useHead } from '@unhead/vue';
import { storeToRefs } from 'pinia';
import { computed, ref, unref } from 'vue';
import { useI18n } from 'vue-i18n';
import ContinueAs from './components/continue-as.vue';
import { LdapForm, LoginForm } from './components/login-form/';
import SsoLinks from './components/sso-links.vue';
import { useHead } from '@unhead/vue';

withDefaults(
defineProps<{
Expand Down Expand Up @@ -62,18 +62,25 @@ useHead({

<sso-links v-if="!authenticated" :providers="auth.providers" />

<div v-if="!authenticated && serverStore.info.project?.is_public_registration_enabled" class="registration-wrapper">
{{ t('dont_have_an_account') }}
<router-link to="/register" class="registration-link">
{{ t('sign_up_now') }}
</router-link>
</div>

<template #notice>
<div v-if="authenticated">
<template v-if="authenticated">
<v-icon name="lock_open" left />
{{ t('authenticated') }}
</div>
<div v-else>
{{
logoutReason && te(`logoutReason.${logoutReason}`)
? t(`logoutReason.${logoutReason}`)
: t('not_authenticated')
}}
</div>
</template>
<template v-else-if="logoutReason && te(`logoutReason.${logoutReason}`)">
{{ t(`logoutReason.${logoutReason}`) }}
</template>
<template v-else>
<v-icon name="lock" left />
{{ t('not_authenticated') }}
</template>
</template>
</public-view>
</template>
Expand All @@ -83,6 +90,21 @@ h1 {
margin-bottom: 20px;
}

.registration-wrapper {
margin-top: 3rem;
display: flex;
flex-wrap: wrap;
justify-content: center;
align-items: center;
gap: 0.5rem;
text-align: center;
color: var(--theme--foreground-subdued);
}

.registration-link {
color: var(--theme--foreground);
}

.header {
display: flex;
align-items: end;
Expand Down