Skip to content

Commit

Permalink
Add public registration (#22125)
Browse files Browse the repository at this point in the history
* WIP: add new register dummy-route

* fix notice on register route

* WIP register form

* WIP: registering ui and controller for testing

* fix lint ordering problem

* wip: users service

* add migration, initial style for fields in settings

* redo how emails will be filtered

* WIP add filter in the register handler

* conditionally render register link depending on settings

* WIP: add email validation

* wip add email sending

* make clicking the email link work

* rm console log

* update controller

* dont send emails for existing emails

* add translation

* only show register link when unauthenticated

* add different redirects

* only allow selecting non-admin roles

* redirect to users page

* update translation

* move logic from controller to usersservice

* rm remnant of logic from controller

* add stall time to registration

* update translation

* rm comments

* rm unused var

* add changeset

* update translation for success

* remove sso related stuff from registration

* also allow setting first and last name

* update error check

* add @directus/errors to app

* replace error strings with enum

* rename to public_registration

* rename to public_registration_verify_email

* add notes to fields

* add types package to changeset

* dont stall if no work is being done

* allow null-role and resending of reg. email

* add public registration env vars, rm RATE_LIMITER_GLOBAL_STORE

RATE_LIMITER_GLOBAL_STORE wasnt being used. Lets just stick to RATE_LIMITER_STORE for all rate limiters. TODO: also remove from docs!

* use ratelimiter for registration, use stall time env var

* add registration limiter docs, rm global store variable from docs

* update changeset

* add ignore-notice

Co-authored-by: Hannes Küttner <[email protected]>

* use and document new `EMAIL_VERIFICATION_TOKEN_TTL`, also doc `REGISTER_STALL_TIME`

* change variable name

Co-authored-by: ian <[email protected]>

* apply variable rename to usage

* change backticks to single quote

Co-authored-by: ian <[email protected]>

* inline variables

* add fields to server info, update types

- The other ratelimiters also expose points and duration, done
- Add `public_registration_verify_email` so that we can render different success messages

* tiny wording tweak of registration mail

* add new user status 'unverified' and check for it

* add unverified status translation

* decouple email verification and validation

* enable register rate limiter by default and up its config

* add autocomplete=new-password on the registration form

* added sdk functions

* add gql query for new fields

* added register api reference

* updated verify sdk function name

* added reference block for email verify endpoint

* updated reference examples

* WIP: add gql resolvers

* add ratelimiter to mutation

* remove ratelimiter registration point+duration info

* rm points and duration from gql

* Update docs/reference/system/users.md

Co-authored-by: Pascal Jufer <[email protected]>

---------

Co-authored-by: Hannes Küttner <[email protected]>
Co-authored-by: ian <[email protected]>
Co-authored-by: Brainslug <[email protected]>
Co-authored-by: Brainslug <[email protected]>
Co-authored-by: Pascal Jufer <[email protected]>
  • Loading branch information
6 people committed May 7, 2024
1 parent 1d7e0b7 commit c893b9f
Show file tree
Hide file tree
Showing 26 changed files with 944 additions and 30 deletions.
10 changes: 10 additions & 0 deletions .changeset/dry-otters-attend.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
'@directus/system-data': patch
'@directus/api': minor
'@directus/app': minor
'@directus/sdk': patch
'@directus/types': patch
'@directus/env': patch
---

Added API and UI for public user registration
44 changes: 43 additions & 1 deletion api/src/controllers/users.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,10 @@ import {
InvalidPayloadError,
isDirectusError,
} from '@directus/errors';
import type { PrimaryKey, Role } from '@directus/types';
import type { PrimaryKey, RegisterUserInput, Role } from '@directus/types';
import express from 'express';
import Joi from 'joi';
import checkRateLimit from '../middleware/rate-limiter-registration.js';
import { respond } from '../middleware/respond.js';
import useCollection from '../middleware/use-collection.js';
import { validateBatch } from '../middleware/validate-batch.js';
Expand Down Expand Up @@ -501,4 +502,45 @@ router.post(
respond,
);

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

router.post(
'/register',
checkRateLimit,
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);

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

const verifyRegistrationSchema = Joi.string();

router.get(
'/register/verify-email',
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}`);
}),
respond,
);

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

export async function up(knex: Knex): Promise<void> {
await knex.schema.alterTable('directus_settings', (table) => {
table.boolean('public_registration').notNullable().defaultTo(false);
table.boolean('public_registration_verify_email').notNullable().defaultTo(true);
table.uuid('public_registration_role').nullable();
table.foreign('public_registration_role').references('directus_roles.id').onDelete('SET NULL');
table.json('public_registration_email_filter').nullable();
});
}

export async function down(knex: Knex): Promise<void> {
await knex.schema.alterTable('directus_settings', (table) => {
table.dropColumns(
'public_registration',
'public_registration_verify_email',
'public_registration_role',
'public_registration_email_filter',
);
});
}
2 changes: 1 addition & 1 deletion api/src/middleware/rate-limiter-global.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ let checkRateLimit: RequestHandler = (_req, _res, next) => next();
export let rateLimiterGlobal: RateLimiterRedis | RateLimiterMemory;

if (env['RATE_LIMITER_GLOBAL_ENABLED'] === true) {
validateEnv(['RATE_LIMITER_GLOBAL_STORE', 'RATE_LIMITER_GLOBAL_DURATION', 'RATE_LIMITER_GLOBAL_POINTS']);
validateEnv(['RATE_LIMITER_GLOBAL_DURATION', 'RATE_LIMITER_GLOBAL_POINTS']);
validateConfiguration();

rateLimiterGlobal = createRateLimiter('RATE_LIMITER_GLOBAL');
Expand Down
42 changes: 42 additions & 0 deletions api/src/middleware/rate-limiter-registration.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { useEnv } from '@directus/env';
import { HitRateLimitError } from '@directus/errors';
import type { RequestHandler } from 'express';
import type { RateLimiterMemory, RateLimiterRedis } from 'rate-limiter-flexible';
import { createRateLimiter } from '../rate-limiter.js';
import asyncHandler from '../utils/async-handler.js';
import { getIPFromReq } from '../utils/get-ip-from-req.js';
import { validateEnv } from '../utils/validate-env.js';

let checkRateLimit: RequestHandler = (_req, _res, next) => next();

export let rateLimiter: RateLimiterRedis | RateLimiterMemory;

const env = useEnv();

if (env['RATE_LIMITER_REGISTRATION_ENABLED'] === true) {
validateEnv(['RATE_LIMITER_REGISTRATION_DURATION', 'RATE_LIMITER_REGISTRATION_POINTS']);

rateLimiter = createRateLimiter('RATE_LIMITER_REGISTRATION');

checkRateLimit = asyncHandler(async (req, res, next) => {
const ip = getIPFromReq(req);

if (ip) {
try {
await rateLimiter.consume(ip, 1);
} catch (rateLimiterRes: any) {
if (rateLimiterRes instanceof Error) throw rateLimiterRes;

res.set('Retry-After', String(Math.round(rateLimiterRes.msBeforeNext / 1000)));
throw new HitRateLimitError({
limit: +(env['RATE_LIMITER_REGISTRATION_POINTS'] as string),
reset: new Date(Date.now() + rateLimiterRes.msBeforeNext),
});
}
}

next();
});
}

export default checkRateLimit;
42 changes: 42 additions & 0 deletions api/src/services/graphql/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,9 +54,11 @@ import {
SESSION_COOKIE_OPTIONS,
} from '../../constants.js';
import getDatabase from '../../database/index.js';
import { rateLimiter } from '../../middleware/rate-limiter-registration.js';
import type { AbstractServiceOptions, AuthenticationMode, GraphQLParams } from '../../types/index.js';
import { generateHash } from '../../utils/generate-hash.js';
import { getGraphQLType } from '../../utils/get-graphql-type.js';
import { getIPFromReq } from '../../utils/get-ip-from-req.js';
import { getSecret } from '../../utils/get-secret.js';
import { getService } from '../../utils/get-service.js';
import isDirectusJWT from '../../utils/is-directus-jwt.js';
Expand Down Expand Up @@ -2053,6 +2055,8 @@ export class GraphQLService {
public_background: { type: GraphQLString },
public_note: { type: GraphQLString },
custom_css: { type: GraphQLString },
public_registration: { type: GraphQLBoolean },
public_registration_verify_email: { type: GraphQLBoolean },
},
}),
},
Expand Down Expand Up @@ -2622,6 +2626,44 @@ export class GraphQLService {
return true;
},
},
users_register: {
type: GraphQLBoolean,
args: {
email: new GraphQLNonNull(GraphQLString),
password: new GraphQLNonNull(GraphQLString),
first_name: GraphQLString,
last_name: GraphQLString,
},
resolve: async (_, args, { req }) => {
const service = new UsersService({ accountability: null, schema: this.schema });

const ip = req ? getIPFromReq(req) : null;

if (ip) {
await rateLimiter.consume(ip);
}

await service.registerUser({
email: args.email,
password: args.password,
first_name: args.first_name,
last_name: args.last_name,
});

return true;
},
},
users_register_verify: {
type: GraphQLBoolean,
args: {
token: new GraphQLNonNull(GraphQLString),
},
resolve: async (_, args) => {
const service = new UsersService({ accountability: null, schema: this.schema });
await service.verifyRegistration(args.token);
return true;
},
},
});

if ('directus_collections' in schema.read.collections) {
Expand Down
37 changes: 37 additions & 0 deletions api/src/services/mail/templates/user-registration.liquid
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
{% layout 'base' %} {% block content %}

<h1>Verify your email address</h1>

<p style='padding-bottom: 30px'>
Thanks for registering at <i>{{ projectName }}</i>.
To complete your registration you need to verify your email address by opening the following verification-link.
Please feel free to ignore this email if you have not personally initiated the registration.
</p>

<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 %}
2 changes: 2 additions & 0 deletions api/src/services/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,8 @@ export class ServerService {
'public_favicon',
'public_note',
'custom_css',
'public_registration',
'public_registration_verify_email',
],
});

Expand Down
111 changes: 109 additions & 2 deletions api/src/services/users.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
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 type { Item, PrimaryKey, Query, RegisterUserInput, 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 @@ -452,6 +452,113 @@ export class UsersService extends ItemsService {
await service.updateOne(user.id, { password, status: 'active' });
}

async registerUser(input: RegisterUserInput) {
const STALL_TIME = env['REGISTER_STALL_TIME'] as number;
const timeStart = performance.now();
const serviceOptions: AbstractServiceOptions = { accountability: this.accountability, schema: this.schema };
const settingsService = new SettingsService(serviceOptions);

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

if (settings?.['public_registration'] == false) {
throw new ForbiddenError();
}

const publicRegistrationRole = settings?.['public_registration_role'] ?? null;
const hasEmailVerification = settings?.['public_registration_verify_email'];
const emailFilter = settings?.['public_registration_email_filter'];
const first_name = input.first_name ?? null;
const last_name = input.last_name ?? null;

const partialUser: Partial<User> = {
// Required fields
email: input.email,
password: input.password,
role: publicRegistrationRole,
status: hasEmailVerification ? 'unverified' : 'active',
// Optional fields
first_name,
last_name,
};

if (emailFilter && validatePayload(emailFilter, { email: input.email }).length !== 0) {
await stall(STALL_TIME, timeStart);
throw new ForbiddenError();
}

const user = await this.getUserByEmail(input.email);

if (isEmpty(user)) {
await this.createOne(partialUser);
}
// We want to be able to re-send the verification email
else if (user.status !== ('unverified' satisfies User['status'])) {
// To avoid giving attackers infos about registered emails we dont fail for violated unique constraints
await stall(STALL_TIME, timeStart);
return;
}

if (hasEmailVerification) {
const mailService = new MailService(serviceOptions);
const payload = { email: input.email, scope: 'pending-registration' };

const token = jwt.sign(payload, env['SECRET'] as string, {
expiresIn: env['EMAIL_VERIFICATION_TOKEN_TTL'] as string,
issuer: 'directus',
});

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

mailService
.send({
to: input.email,
subject: 'Verify your email address', // TODO: translate after theres support for internationalized emails
template: {
name: 'user-registration',
data: {
url: verificationURL.toString(),
email: input.email,
first_name,
last_name,
},
},
})
.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);

if (user?.status !== ('unverified' satisfies User['status'])) {
throw new InvalidPayloadError({ reason: 'Invalid verification code' });
}

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
1 change: 1 addition & 0 deletions app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
"devDependencies": {
"@directus/composables": "workspace:*",
"@directus/constants": "workspace:*",
"@directus/errors": "workspace:*",
"@directus/extensions": "workspace:*",
"@directus/extensions-registry": "workspace:*",
"@directus/extensions-sdk": "workspace:*",
Expand Down

0 comments on commit c893b9f

Please sign in to comment.