Skip to content

Instantly share code, notes, and snippets.

@iksent
Created August 13, 2020 11:10
Show Gist options
  • Star 8 You must be signed in to star a gist
  • Fork 2 You must be signed in to fork a gist
  • Save iksent/32a9b48fcf33ec3cfad966db7564cdc6 to your computer and use it in GitHub Desktop.
Save iksent/32a9b48fcf33ec3cfad966db7564cdc6 to your computer and use it in GitHub Desktop.
Directus Public User Registration (+ Email Confirmation!)
{% extends "base.twig" %}
{% block content %}
<p>Hey {{ user_full_name }},</p>
<p>To confirm your email click here:</p>
<p><a href="{{ url }}?token={{ token }}">Confirm my Email</a></p>
<p> Love, <br>Directus</p>
{% endblock %}
<?php
require_once __DIR__ . '/SignupConfig.php';
use Directus\Application\Application;
use Directus\Application\Http\Request;
use Directus\Application\Http\Response;
use Directus\Authentication\Exception\ExpiredRequestTokenException;
use Directus\Authentication\Exception\InvalidTokenException;
use Directus\Util\JWTUtils;
class Confirm extends SignupConfig {
private $response_code;
private $result;
private $container;
private $auth;
private $userProvider;
public function __construct() {
$this->response_code = parent::RESPONSE_CODE_SUCCESS;
$this->result = null;
$this->container = Application::getInstance()->getContainer();
$this->auth = $this->container->get( 'auth' );
$this->userProvider = $this->auth->getUserProvider();
}
public function __invoke( Request $request, Response $response ) {
$this->process_request( $request );
$response_key = ( $this->response_code < parent::RESPONSE_CODE_ERROR_BAD_REQUEST ) ? 'data' : 'error';
return $response->withStatus( $this->response_code )->withJson( [ $response_key => $this->result ] );
}
private function process_request( $request ) {
if ( parent::WITH_EMAIL_CONFIRMATION ) {
$token = $request->getParam( 'token' );
$ignoreOrigin = $request->getAttribute( 'ignore_origin' );
if ( ! $token ) {
$this->response_code = parent::RESPONSE_CODE_ERROR_BAD_REQUEST;
$this->result = [
'code' => 'no_token',
'message' => 'Token was not found',
];
return;
}
$this->validate_token( $token, $ignoreOrigin );
} else {
$this->response_code = parent::RESPONSE_CODE_ERROR_BAD_REQUEST;
$this->result = [
'code' => 'not_supported',
'message' => '',
];
}
}
private function validate_token( $token, $ignoreOrigin ) {
if ( JWTUtils::hasExpired( $token ) ) {
throw new ExpiredRequestTokenException();
}
$payload = $this->getTokenPayload( $token, $ignoreOrigin );
$user = $this->userProvider->findWhere( [
'id' => $payload->id,
'confirm_email_token' => $token,
] );
if ( $user ) {
$this->userProvider->update( $user, [
'status' => 'active',
'confirm_email_token' => null,
] );
} else {
throw new InvalidTokenException();
}
$this->result = [
'code' => 'confirmed',
'message' => 'Email was successfully confirmed',
];
}
private function getTokenPayload( $token, $ignoreOrigin ) {
// Copying code from getTokenPayload function, because of protected getTokenAlgorithm function
$algorithm = 'HS256';
$projectName = $ignoreOrigin ? JWTUtils::getPayload( $token, 'project' ) : null;
$payload = JWTUtils::decode( $token, $this->auth->getSecretKey( $projectName ), [ $algorithm ] );
if ( $ignoreOrigin !== true && ! $this->auth->isPayloadLocal( $payload ) ) {
// Empty payload, log this as debug?
throw new InvalidTokenException();
}
return $payload;
}
}
<?php
require __DIR__ . '/Signup.php';
require __DIR__ . '/Confirm.php';
return [
'' => [
'method' => 'POST',
'handler' => Signup::class
],
'confirm' => [
'method' => 'POST',
'handler' => Confirm::class
],
];
<?php
require_once __DIR__ . '/SignupConfig.php';
use Directus\Application\Application;
use Directus\Application\Http\Request;
use Directus\Application\Http\Response;
use Directus\Mail\Exception\MailNotSentException;
use Directus\Mail\Message;
use Directus\Util\DateTimeUtils;
use Directus\Util\JWTUtils;
use Zend\Db\TableGateway\TableGateway;
use function Directus\generate_uuid4;
use function Directus\get_directus_setting;
use function Directus\send_mail_with_template;
class Signup extends SignupConfig {
private $db_connection;
private $table_users;
private $response_code;
private $result;
private $container;
private $exist_user;
private $auth;
private $userProvider;
public function __construct() {
$this->response_code = parent::RESPONSE_CODE_SUCCESS;
$this->result = null;
$this->container = Application::getInstance()->getContainer();
$this->db_connection = $this->container->get( 'database' );
$this->table_users = new TableGateway( parent::TABLE_USERS, $this->db_connection );
$this->auth = $this->container->get( 'auth' );
$this->userProvider = $this->auth->getUserProvider();
}
public function __invoke( Request $request, Response $response ) {
$this->process_request( $request );
$response_key = ( $this->response_code < parent::RESPONSE_CODE_ERROR_BAD_REQUEST ) ? 'data' : 'error';
return $response->withStatus( $this->response_code )->withJson( [ $response_key => $this->result ] );
}
private function process_request( $request ) {
$user_data = [
'email' => $request->getParam( 'email' ),
'first_name' => $request->getParam( 'first_name' ),
'last_name' => $request->getParam( 'last_name' ),
'password' => $request->getParam( 'password' ),
];
$confirm_url = $request->getParam( 'confirm_url' );
if ( parent::WITH_EMAIL_CONFIRMATION && ! $confirm_url ) {
$this->response_code = parent::RESPONSE_CODE_ERROR_BAD_REQUEST;
$this->result = [
'code' => 'no_confirm_url',
'message' => 'Confirmation URL was not specified',
];
return;
}
// validation process
$this->validate_fields( $user_data );
$this->validate_existing_user( $user_data['email'], $confirm_url );
if ( $this->response_code < parent::RESPONSE_CODE_ERROR_BAD_REQUEST ) {
$new_user = $this->save_user( $user_data );
if ( parent::WITH_EMAIL_CONFIRMATION ) {
$this->send_confirm_email( $new_user, $confirm_url );
}
}
}
private function validate_fields( $user_data ) {
$is_valid = true;
$errors = [];
// validate email
if ( ! preg_match( parent::EMAIL_REGEX, $user_data['email'] ) ) {
$is_valid = false;
$errors['email'] = 'This value is not a valid email address.';
}
// validate required fields
foreach ( $user_data as $key => $value ) {
if ( empty( $value ) ) {
$is_valid = false;
$errors[ $key ] = 'This field is required.';
}
}
// build validation error response codes
if ( ! $is_valid ) {
$this->response_code = parent::RESPONSE_CODE_ERROR_UNPROCESSABLE_ENTITY;
$this->result = [
'code' => 'validation_errors',
'message' => 'There are some validation errors',
'errors' => $errors,
];
}
}
private function validate_existing_user( $email, $confirm_url ) {
if ( empty( $this->result['message'] ) ) {
// retrieve user by email
$user = $this->userProvider->findByEmail( $email );
// verify if user exists
if ( $user ) {
$this->exist_user = $user;
if ( $user->status === 'draft' ) {
if ( parent::WITH_EMAIL_CONFIRMATION ) {
$user_array = $user->toArray();
$token = $user_array[ self::CONFIRM_TOKEN_COLUMN ];
if ( JWTUtils::hasExpired( $token ) ) {
$this->send_confirm_email( $user, $confirm_url );
$this->response_code = parent::RESPONSE_CODE_ERROR_CONFLICT;
$this->result = [
'code' => 'email_resent',
'message' => 'Confirmation email was send again to ' . $email . '',
];
} else {
$this->response_code = parent::RESPONSE_CODE_ERROR_CONFLICT;
$this->result = [
'code' => 'check_email',
'message' => 'Check your email ' . $email . ' for confirmation link',
];
}
}
} else if ( $user->status !== 'deleted' ) {
$this->response_code = parent::RESPONSE_CODE_ERROR_CONFLICT;
$this->result = [
'code' => 'user_exists',
'message' => 'User with email ' . $email . ' already exists',
];
}
}
}
}
private function save_user( $user_data ) {
$user_data['role'] = parent::USERS_ROLE_ID;
$user_data['status'] = parent::WITH_EMAIL_CONFIRMATION ? 'draft' : 'active';
$user_data['password'] = password_hash( $user_data['password'], PASSWORD_BCRYPT, [ 'cost' => 10 ] );
$user_data['external_id'] = generate_uuid4();
if ( $this->exist_user ) {
$user_id = $this->exist_user->id;
$this->userProvider->update( $this->exist_user, $user_data );
} else {
$this->table_users->insert( $user_data );
$user_id = $this->table_users->getLastInsertValue();
}
// retrieve inserted user without sensible data
$new_user = $this->userProvider->find( $user_id );
$result = $new_user->toArray();
unset( $result['email_notifications'] );
unset( $result['last_access_on'] );
unset( $result['last_page'] );
unset( $result['password'] );
unset( $result['token'] );
unset( $result['password_reset_token'] );
unset( $result[ parent::CONFIRM_TOKEN_COLUMN ] );
$this->result = $result;
return $new_user;
}
private function send_confirm_email( $new_user, $url ) {
$token = $this->generateToken( $new_user );
// Storing the token into confirm_email_token to validate it.
$this->userProvider->update( $new_user, [
parent::CONFIRM_TOKEN_COLUMN => $token
] );
// Sending Email
$names = array_filter( [ $new_user->first_name, $new_user->last_name ] );
$data = [
'url' => $url,
'token' => $token,
'user_full_name' => ! empty( $names ) ? implode( ' ', $names ) : '',
];
try {
send_mail_with_template( 'confirm-email.twig', $data, function ( Message $message ) use ( $new_user ) {
$message->setSubject(
sprintf( 'Confirm Email: %s', get_directus_setting( 'project_name', '' ) )
);
$message->setTo( $new_user->email );
} );
} catch ( \Exception $e ) {
$this->container->get( 'logger' )->error( $e->getMessage() );
if ( ! empty( $e->getCode() ) ) {
throw $e;
}
throw new MailNotSentException();
}
}
private function generateToken( $user ) {
$datetime = DateTimeUtils::nowInUTC();
return $this->auth->generateToken( 'confirm_email', [
'date' => $datetime->toString(),
'exp' => $datetime->inDays( 30 )->getTimestamp(),
'id' => $user->id,
'email' => $user->email,
] );
}
}
<?php
class SignupConfig {
const WITH_EMAIL_CONFIRMATION = true;
const USERS_ROLE_ID = 3;
const TABLE_USERS = 'directus_users';
const CONFIRM_TOKEN_COLUMN = 'confirm_email_token';
const RESPONSE_CODE_ERROR_BAD_REQUEST = 400;
const RESPONSE_CODE_ERROR_CONFLICT = 409;
const RESPONSE_CODE_ERROR_UNPROCESSABLE_ENTITY = 422;
const RESPONSE_CODE_SUCCESS = 200;
const EMAIL_REGEX = '/^(?!(?:(?:\x22?\x5C[\x00-\x7E]\x22?)|(?:\x22?[^\x5C\x22]\x22?)){255,})(?!(?:(?:\x22?\x5C[\x00-\x7E]\x22?)|(?:\x22?[^\x5C\x22]\x22?)){65,}@)(?:(?:[\x21\x23-\x27\x2A\x2B\x2D\x2F-\x39\x3D\x3F\x5E-\x7E]+)|(?:\x22(?:[\x01-\x08\x0B\x0C\x0E-\x1F\x21\x23-\x5B\x5D-\x7F]|(?:\x5C[\x00-\x7F]))*\x22))(?:\.(?:(?:[\x21\x23-\x27\x2A\x2B\x2D\x2F-\x39\x3D\x3F\x5E-\x7E]+)|(?:\x22(?:[\x01-\x08\x0B\x0C\x0E-\x1F\x21\x23-\x5B\x5D-\x7F]|(?:\x5C[\x00-\x7F]))*\x22)))*@(?:(?:(?!.*[^.]{64,})(?:(?:(?:xn--)?[a-z0-9]+(?:-[a-z0-9]+)*\.){1,126}){1,}(?:(?:[a-z][a-z0-9]*)|(?:(?:xn--)[a-z0-9]+))(?:-[a-z0-9]+)*)|(?:\[(?:(?:IPv6:(?:(?:[a-f0-9]{1,4}(?::[a-f0-9]{1,4}){7})|(?:(?!(?:.*[a-f0-9][:\]]){7,})(?:[a-f0-9]{1,4}(?::[a-f0-9]{1,4}){0,5})?::(?:[a-f0-9]{1,4}(?::[a-f0-9]{1,4}){0,5})?)))|(?:(?:IPv6:(?:(?:[a-f0-9]{1,4}(?::[a-f0-9]{1,4}){5}:)|(?:(?!(?:.*[a-f0-9]:){5,})(?:[a-f0-9]{1,4}(?::[a-f0-9]{1,4}){0,3})?::(?:[a-f0-9]{1,4}(?::[a-f0-9]{1,4}){0,3}:)?)))?(?:(?:25[0-5])|(?:2[0-4][0-9])|(?:1[0-9]{2})|(?:[1-9]?[0-9]))(?:\.(?:(?:25[0-5])|(?:2[0-4][0-9])|(?:1[0-9]{2})|(?:[1-9]?[0-9]))){3}))\]))$/iD';
}
@iksent
Copy link
Author

iksent commented Aug 13, 2020

Don't forget to create a new column for token (VARCHAR 512) at "directus_users" table and then set it's name at SignupConfig.php ("confirm_email_token" by default), only if you need email confirmation.

Files structure:

  • public/extensions/custom/endpoints
    Create new folder signup (or choose your path) and paste all the files here (except twig)
  • public/extensions/custom/mail
    Paste confirm-email.twig here.

Example of usage:

POST /public/<project>/custom/signup

{
	"email": "john@example.com",
	"first_name": "Test",
	"last_name": "Test",
	"password": "password",
	"confirm_url": "/your-frontend-confirm-url-for-email"
}

POST /public/<project>/custom/signup/confirm

{
  "token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzIaNiJ9.eyJkYXRlIjoiMjAyMC0wOC0xMyAxMTowMjo0NCIsImV4cCI6MTU5OckwODU2NCwiaWQiOiIxMyIsImVtYWlsIjoiZm9yZWJhbTkwOUBhfmFzai5uZXQiLCJ0eXBlIjoiY29uZmlybV9lbWFpbCIsImtlefI6IjUzMTZjOTEzLWE2ODctNGJlNC1hZDJlLTJjMDhiN2M2OWFjYyIsInByb2plY3QiOiJ0ZXN0In0.c201KbagU3FuplTYYrD1dJAD7_Q5aoYqEcUabhtOKno"
}

Tested with Directus 8.8.1

Special thanks to @mariorojas for his base code.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment