Created
August 13, 2020 11:10
-
-
Save iksent/32a9b48fcf33ec3cfad966db7564cdc6 to your computer and use it in GitHub Desktop.
Directus Public User Registration (+ Email Confirmation!)
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
{% 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 %} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?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; | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?php | |
require __DIR__ . '/Signup.php'; | |
require __DIR__ . '/Confirm.php'; | |
return [ | |
'' => [ | |
'method' => 'POST', | |
'handler' => Signup::class | |
], | |
'confirm' => [ | |
'method' => 'POST', | |
'handler' => Confirm::class | |
], | |
]; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?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, | |
] ); | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?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'; | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
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
POST
/public/<project>/custom/signup/confirm
Tested with Directus 8.8.1
Special thanks to @mariorojas for his base code.