diff --git a/.changeset/dry-otters-attend.md b/.changeset/dry-otters-attend.md new file mode 100644 index 0000000000000..825ef728df0da --- /dev/null +++ b/.changeset/dry-otters-attend.md @@ -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 diff --git a/api/src/controllers/users.ts b/api/src/controllers/users.ts index 0cd7834154af0..8348cd912a96d 100644 --- a/api/src/controllers/users.ts +++ b/api/src/controllers/users.ts @@ -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'; @@ -501,4 +502,45 @@ router.post( respond, ); +const registerSchema = Joi.object({ + 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; diff --git a/api/src/database/migrations/20240422A-public-registration.ts b/api/src/database/migrations/20240422A-public-registration.ts new file mode 100644 index 0000000000000..1eeba8419cf4b --- /dev/null +++ b/api/src/database/migrations/20240422A-public-registration.ts @@ -0,0 +1,22 @@ +import type { Knex } from 'knex'; + +export async function up(knex: Knex): Promise { + 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 { + await knex.schema.alterTable('directus_settings', (table) => { + table.dropColumns( + 'public_registration', + 'public_registration_verify_email', + 'public_registration_role', + 'public_registration_email_filter', + ); + }); +} diff --git a/api/src/middleware/rate-limiter-global.ts b/api/src/middleware/rate-limiter-global.ts index f30e92cb52f3f..db300c6bba9c7 100644 --- a/api/src/middleware/rate-limiter-global.ts +++ b/api/src/middleware/rate-limiter-global.ts @@ -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'); diff --git a/api/src/middleware/rate-limiter-registration.ts b/api/src/middleware/rate-limiter-registration.ts new file mode 100644 index 0000000000000..099d5dbd0cc86 --- /dev/null +++ b/api/src/middleware/rate-limiter-registration.ts @@ -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; diff --git a/api/src/services/graphql/index.ts b/api/src/services/graphql/index.ts index 9f4f6bdbfb45e..aaf8bb8829285 100644 --- a/api/src/services/graphql/index.ts +++ b/api/src/services/graphql/index.ts @@ -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'; @@ -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 }, }, }), }, @@ -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) { diff --git a/api/src/services/mail/templates/user-registration.liquid b/api/src/services/mail/templates/user-registration.liquid new file mode 100644 index 0000000000000..1888aeb56d9ca --- /dev/null +++ b/api/src/services/mail/templates/user-registration.liquid @@ -0,0 +1,37 @@ +{% layout 'base' %} {% block content %} + +

Verify your email address

+ +

+ Thanks for registering at {{ projectName }}. + 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. +

+ + + + Verify email + + + +{% endblock %} diff --git a/api/src/services/server.ts b/api/src/services/server.ts index 68439f69a35d5..6e4557dd610f0 100644 --- a/api/src/services/server.ts +++ b/api/src/services/server.ts @@ -54,6 +54,8 @@ export class ServerService { 'public_favicon', 'public_note', 'custom_css', + 'public_registration', + 'public_registration_verify_email', ], }); diff --git a/api/src/services/users.ts b/api/src/services/users.ts index afe1c9cf98c28..9b8243ce8f91e 100644 --- a/api/src/services/users.ts +++ b/api/src/services/users.ts @@ -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'; @@ -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 = { + // 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 { + 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 { const STALL_TIME = 500; const timeStart = performance.now(); diff --git a/app/package.json b/app/package.json index d28543de491a3..53aebb8b96bb0 100644 --- a/app/package.json +++ b/app/package.json @@ -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:*", diff --git a/app/src/lang/translations/en-US.yaml b/app/src/lang/translations/en-US.yaml index 77a1847126d7c..b389628d016de 100644 --- a/app/src/lang/translations/en-US.yaml +++ b/app/src/lang/translations/en-US.yaml @@ -1300,6 +1300,7 @@ fields: status: Status status_draft: Draft status_invited: Invited + status_unverified: Unverified status_active: Active status_suspended: Suspended status_archived: Archived @@ -1364,6 +1365,16 @@ fields: custom_aspect_ratios: Custom Aspect Ratios theme_light_overrides: Light Theme Customization theme_dark_overrides: Dark Theme Customization + public_registration: User Registration + public_registration_note: Allows users to register via the [registration route](/admin/register). + public_registration_role: User Role + public_registration_role_note: + This role is assigned to users who register using this method. Does not affect already registered users. + public_registration_email_filter: Email Address Filter + public_registration_email_filter_note: Only email addresses matching this filter can register. + public_registration_verify_email: Verify Email + public_registration_verify_email_note: + Sends an email to the registering user asking them to click on a verification link before they can sign in. directus_shares: name: Name role: Role @@ -1631,6 +1642,14 @@ 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: You may now proceed to the sign in page. +registration_successful_check_email_note: | + Please note that before you can sign in, you'll 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? +sign_up_now: Sign up now sign_in: Sign In sign_out: Sign Out sign_out_confirm: Are you sure you want to sign out? diff --git a/app/src/router.ts b/app/src/router.ts index 0e4264107c2fe..8cbd18ab234b4 100644 --- a/app/src/router.ts +++ b/app/src/router.ts @@ -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'; @@ -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', diff --git a/app/src/routes/login/login.vue b/app/src/routes/login/login.vue index 0f05445b7506c..99705609526b5 100644 --- a/app/src/routes/login/login.vue +++ b/app/src/routes/login/login.vue @@ -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<{ @@ -62,18 +62,25 @@ useHead({ +
+ {{ t('dont_have_an_account') }} + + {{ t('sign_up_now') }} + +
+ @@ -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; diff --git a/app/src/routes/register/register-form.vue b/app/src/routes/register/register-form.vue new file mode 100644 index 0000000000000..83c5916c2d1c1 --- /dev/null +++ b/app/src/routes/register/register-form.vue @@ -0,0 +1,126 @@ + + + + + diff --git a/app/src/routes/register/register.vue b/app/src/routes/register/register.vue new file mode 100644 index 0000000000000..6f82255b9002e --- /dev/null +++ b/app/src/routes/register/register.vue @@ -0,0 +1,119 @@ + + + + + diff --git a/app/src/stores/server.ts b/app/src/stores/server.ts index 708cbde1fe502..3620d9d719ab9 100644 --- a/app/src/stores/server.ts +++ b/app/src/stores/server.ts @@ -32,6 +32,8 @@ export type Info = { public_favicon: string | null; public_note: string | null; custom_css: string | null; + public_registration: boolean | null; + public_registration_verify_email: boolean | null; }; rateLimit?: | false @@ -39,6 +41,12 @@ export type Info = { points: number; duration: number; }; + rateLimitGlobal?: + | false + | { + points: number; + duration: number; + }; queryLimit?: { default: number; max: number; diff --git a/docs/reference/system/users.md b/docs/reference/system/users.md index 04926627d6a43..80e8175397a57 100644 --- a/docs/reference/system/users.md +++ b/docs/reference/system/users.md @@ -1071,6 +1071,192 @@ const result = await client.request( +## Register a new User + +Register a new user. + +### Request + + + + + + + +#### Request Body + +`email` **Required**\ +Email for the new user. + +`password` **Required**\ +Password for the new user. + +`first_name`\ +First name for the new user. + +`last_name`\ +Last name for the new user. + +### Response + +Empty body. + +### Example + + + + + + + +## Verify Registered Email + +Verify the registered email address. The [register user endpoint](#register-a-new-user) sends the email a link for +verification. + +This link includes a token, which is then used to activate the registered user. + +### Request + + + + + + + +#### Query Parameters + +`token` **Required**\ +Emailed registration token. + +### Response + +Empty body. + +### Example + + + + + + + ## Invite a new User Invite a new user by email. diff --git a/docs/self-hosted/config-options.md b/docs/self-hosted/config-options.md index bd62995472f01..63d52425afe6b 100644 --- a/docs/self-hosted/config-options.md +++ b/docs/self-hosted/config-options.md @@ -342,6 +342,7 @@ Redis is required when you run Directus load balanced across multiple containers | ----------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------- | | `SECRET`[1] | Secret string for the project. | Random value | | `ACCESS_TOKEN_TTL` | The duration that the access token is valid. | `15m` | +| `EMAIL_VERIFICATION_TOKEN_TTL` | The duration that the email verification token is valid. | `7d` | | `REFRESH_TOKEN_TTL` | The duration that the refresh token is valid. This value should be higher than `ACCESS_TOKEN_TTL` resp. `SESSION_COOKIE_TTL`. | `7d` | | `REFRESH_TOKEN_COOKIE_DOMAIN` | Which domain to use for the refresh token cookie. Useful for development mode. | -- | | `REFRESH_TOKEN_COOKIE_SECURE` | Whether or not to set the `secure` attribute for the refresh token cookie. | `false` | @@ -353,6 +354,7 @@ Redis is required when you run Directus load balanced across multiple containers | `SESSION_COOKIE_SAME_SITE` | Value for `sameSite` in the session cookie. | `lax` | | `SESSION_COOKIE_NAME` | Name of the session cookie. | `directus_session_token` | | `LOGIN_STALL_TIME` | The duration in milliseconds that a login request will be stalled for, and it should be greater than the time taken for a login request with an invalid password | `500` | +| `REGISTER_STALL_TIME` | The duration in milliseconds that a registration request will be stalled for, and it should be greater than the time taken for a registration request with an already registered email | `750` | | `PASSWORD_RESET_URL_ALLOW_LIST` | List of URLs that can be used [as `reset_url` in /password/request](/reference/authentication#request-password-reset) | -- | | `USER_INVITE_URL_ALLOW_LIST` | List of URLs that can be used [as `invite_url` in /users/invite](/reference/system/users#invite-a-new-user) | -- | | `IP_TRUST_PROXY` | Settings for [express' trust proxy setting](https://expressjs.com/en/guide/behind-proxies.html) | true | @@ -427,20 +429,22 @@ For more details about each configuration variable, please see the You can use the built-in rate-limiter to prevent users from hitting the API too much. Simply enabling the rate-limiter will set a default maximum of 50 requests per second, tracked in memory. -| Variable | Description | Default Value | -| ------------------------------------------- | ----------------------------------------------------------------- | ------------- | -| `RATE_LIMITER_ENABLED` | Whether or not to enable rate limiting per IP on the API. | `false` | -| `RATE_LIMITER_POINTS` | The amount of allowed hits per duration. | `50` | -| `RATE_LIMITER_DURATION` | The time window in seconds in which the points are counted. | `1` | -| `RATE_LIMITER_STORE` | Where to store the rate limiter counts. One of `memory`, `redis`. | `memory` | -| `RATE_LIMITER_HEALTHCHECK_THRESHOLD` | Healthcheck timeout threshold in ms. | `150` | -| `RATE_LIMITER_GLOBAL_ENABLED` | Whether or not to enable global rate limiting on the API. | `false` | -| `RATE_LIMITER_GLOBAL_POINTS` | The total amount of allowed hits per duration. | `1000` | -| `RATE_LIMITER_GLOBAL_DURATION` | The time window in seconds in which the points are counted. | `1` | -| `RATE_LIMITER_GLOBAL_HEALTHCHECK_THRESHOLD` | Healthcheck timeout threshold in ms. | `150` | -| `RATE_LIMITER_GLOBAL_STORE` | Where to store the rate limiter counts. One of `memory`, `redis`. | `memory` | - -Based on the `RATE_LIMITER_STORE`/`RATE_LIMITER_GLOBAL_STORE` used, you must also provide the following configurations: +| Variable | Description | Default Value | +| ------------------------------------------- | ----------------------------------------------------------------------- | ------------- | +| `RATE_LIMITER_ENABLED` | Whether or not to enable rate limiting per IP on the API. | `false` | +| `RATE_LIMITER_POINTS` | The amount of allowed hits per duration. | `50` | +| `RATE_LIMITER_DURATION` | The time window in seconds in which the points are counted. | `1` | +| `RATE_LIMITER_STORE` | Where to store the rate limiter counts. One of `memory`, `redis`. | `memory` | +| `RATE_LIMITER_HEALTHCHECK_THRESHOLD` | Healthcheck timeout threshold in ms. | `150` | +| `RATE_LIMITER_GLOBAL_ENABLED` | Whether or not to enable global rate limiting on the API. | `false` | +| `RATE_LIMITER_GLOBAL_POINTS` | The total amount of allowed hits per duration. | `1000` | +| `RATE_LIMITER_GLOBAL_DURATION` | The time window in seconds in which the points are counted. | `1` | +| `RATE_LIMITER_GLOBAL_HEALTHCHECK_THRESHOLD` | Healthcheck timeout threshold in ms. | `150` | +| `RATE_LIMITER_REGISTRATION_ENABLED` | Whether or not to enable rate limiting per IP on the user registration. | `true` | +| `RATE_LIMITER_REGISTRATION_POINTS` | The amount of allowed hits per duration. | `5` | +| `RATE_LIMITER_REGISTRATION_DURATION` | The time window in seconds in which the points are counted. | `60` | + +Based on the `RATE_LIMITER_STORE` used, you must also provide the following configurations: ### Example: Basic diff --git a/packages/env/src/constants/defaults.ts b/packages/env/src/constants/defaults.ts index 327e7ea1e9f8e..a99b062514578 100644 --- a/packages/env/src/constants/defaults.ts +++ b/packages/env/src/constants/defaults.ts @@ -29,9 +29,13 @@ export const DEFAULTS = { RATE_LIMITER_GLOBAL_ENABLED: false, RATE_LIMITER_GLOBAL_POINTS: 1000, RATE_LIMITER_GLOBAL_DURATION: 1, - RATE_LIMITER_GLOBAL_STORE: 'memory', + + RATE_LIMITER_REGISTRATION_ENABLED: true, + RATE_LIMITER_REGISTRATION_POINTS: 5, + RATE_LIMITER_REGISTRATION_DURATION: 60, ACCESS_TOKEN_TTL: '15m', + EMAIL_VERIFICATION_TOKEN_TTL: '7d', REFRESH_TOKEN_TTL: '7d', REFRESH_TOKEN_COOKIE_NAME: 'directus_refresh_token', @@ -44,6 +48,7 @@ export const DEFAULTS = { SESSION_COOKIE_SAME_SITE: 'lax', LOGIN_STALL_TIME: 500, + REGISTER_STALL_TIME: 750, SERVER_SHUTDOWN_TIMEOUT: 1000, ROOT_REDIRECT: './admin', diff --git a/packages/env/src/constants/directus-variables.ts b/packages/env/src/constants/directus-variables.ts index 56fea65bf01c3..306f302ba12e6 100644 --- a/packages/env/src/constants/directus-variables.ts +++ b/packages/env/src/constants/directus-variables.ts @@ -35,6 +35,7 @@ export const DIRECTUS_VARIABLES = [ 'SECRET', 'ACCESS_TOKEN_TTL', + 'EMAIL_VERIFICATION_TOKEN_TTL', 'REFRESH_TOKEN_TTL', 'REFRESH_TOKEN_COOKIE_NAME', 'REFRESH_TOKEN_COOKIE_DOMAIN', @@ -56,6 +57,7 @@ export const DIRECTUS_VARIABLES = [ 'REDIS_DB', 'LOGIN_STALL_TIME', + 'REGISTER_STALL_TIME', 'PASSWORD_RESET_URL_ALLOW_LIST', 'USER_INVITE_URL_ALLOW_LIST', 'IP_TRUST_PROXY', @@ -80,6 +82,7 @@ export const DIRECTUS_VARIABLES = [ // rate limiting 'RATE_LIMITER_GLOBAL_.+', 'RATE_LIMITER_.+', + 'RATE_LIMITER_REGISTRATION_.+', // cache 'CACHE_ENABLED', diff --git a/packages/system-data/src/fields/settings.yaml b/packages/system-data/src/fields/settings.yaml index 5627c1427af17..314053b256b35 100644 --- a/packages/system-data/src/fields/settings.yaml +++ b/packages/system-data/src/fields/settings.yaml @@ -200,6 +200,60 @@ fields: placeholder: $t:unlimited width: half + - field: public_registration_divider + interface: presentation-divider + options: + icon: person_add + title: $t:fields.directus_settings.public_registration + special: + - alias + - no-data + width: full + + - field: public_registration + interface: boolean + note: $t:fields.directus_settings.public_registration_note + width: half + options: + label: $t:enabled + special: + - cast-boolean + + - field: public_registration_role + interface: select-dropdown-m2o + note: $t:fields.directus_settings.public_registration_role_note + options: + template: '{{ name }}' + filter: + admin_access: + _eq: false + special: + - m2o + width: half + display: related-values + display_options: + template: '{{ name }}' + + - field: public_registration_verify_email + interface: boolean + note: $t:fields.directus_settings.public_registration_verify_email_note + width: half + options: + label: $t:enabled + special: + - cast-boolean + + - field: public_registration_email_filter + interface: system-filter + note: $t:fields.directus_settings.public_registration_email_filter_note + options: + collectionName: directus_users + collectionField: email + fieldName: email + special: + - cast-json + width: half + - field: files_divider interface: presentation-divider options: diff --git a/packages/system-data/src/fields/users.yaml b/packages/system-data/src/fields/users.yaml index fcee97a88eb14..cd70aefc5fc9b 100644 --- a/packages/system-data/src/fields/users.yaml +++ b/packages/system-data/src/fields/users.yaml @@ -163,6 +163,8 @@ fields: value: draft - text: $t:fields.directus_users.status_invited value: invited + - text: $t:fields.directus_users.status_unverified + value: unverified - text: $t:fields.directus_users.status_active value: active - text: $t:fields.directus_users.status_suspended diff --git a/packages/types/src/users.ts b/packages/types/src/users.ts index a605a009eb2ad..96866d2a5cc1c 100644 --- a/packages/types/src/users.ts +++ b/packages/types/src/users.ts @@ -17,7 +17,7 @@ export type Avatar = { export type User = { id: string; - status: 'draft' | 'invited' | 'active' | 'suspended' | 'archived'; + status: 'draft' | 'invited' | 'unverified' | 'active' | 'suspended' | 'archived'; first_name: string | null; last_name: string | null; email: string | null; @@ -43,3 +43,10 @@ export type User = { tags: string[] | null; email_notifications: boolean; }; + +export type RegisterUserInput = { + email: NonNullable; + password: NonNullable; + first_name?: User['first_name']; + last_name?: User['last_name']; +}; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7a1d0c83fb0ec..6d68d0c3da8ff 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -526,6 +526,9 @@ importers: '@directus/constants': specifier: workspace:* version: link:../packages/constants + '@directus/errors': + specifier: workspace:* + version: link:../packages/errors '@directus/extensions': specifier: workspace:* version: link:../packages/extensions diff --git a/sdk/src/rest/commands/server/info.ts b/sdk/src/rest/commands/server/info.ts index 411d46f825c9a..941abaa34edaf 100644 --- a/sdk/src/rest/commands/server/info.ts +++ b/sdk/src/rest/commands/server/info.ts @@ -5,6 +5,8 @@ export type ServerInfoOutput = { project: { project_name: string; default_language: string; + public_registration: boolean; + public_registration_verify_email: boolean; }; rateLimit?: | { diff --git a/sdk/src/rest/commands/utils/users.ts b/sdk/src/rest/commands/utils/users.ts index 9dda3bfabf948..f489df776566e 100644 --- a/sdk/src/rest/commands/utils/users.ts +++ b/sdk/src/rest/commands/utils/users.ts @@ -40,6 +40,46 @@ export const acceptUserInvite = }), }); +/** + * Register a new user. + * + * @param email The new user email. + * @param password The new user password. + * @param options Optional registration fields. + * + * @returns Nothing + */ +export const registerUser = + ( + email: string, + password: string, + options: { first_name?: string; last_name?: string } = {}, + ): RestCommand => + () => ({ + path: `/users/register`, + method: 'POST', + body: JSON.stringify({ + email, + password, + ...options, + }), + }); + +/** + * Verify a registered user email using a token sent to the address. + * + * @param token Accept registration token. + * + * @returns Nothing + */ +export const registerUserVerify = + (token: string): RestCommand => + () => ({ + path: `/register/verify-email`, + params: { token }, + method: 'GET', + }); + /** * Generates a secret and returns the URL to be used in an authenticator app. *