Magic links are... magic(ish)
A magic link is a fancy term for a link that is emailed to you that when clicked, logs you into the platform without having to enter a password. Since emails can be compromised you'll ideally want to have additional identity-confirming procedures in place like 2FA to ensure that the person who clicked the link is the right person.
In this tutorial, we're going to look at building a simple system that sends a magic link to a user, that when clicked logs them into your site. I won't be going through the setup of login systems or creating account areas in this article, if you'd like to learn how to do that read the basic setup tutorial for the Login Extra.
There are many different solutions to this particular problem, I've decided to go down the custom database table route instead of using MODX User Profiles for storing login tokens simply because I wanted to show the flexibility and extensibility of MODX. The script below could be easily adapted to work with MODX Profiles if you so choose.
Getting started
If you plan to follow along you'll need to have MIGX extra installed, we'll be using the CMP generator functionality to create our database tables.
The software flow is as follows:
- The user enters their email address in the login form and clicks Send Magic Link
- MODX searches for a user with this email address (to keep things simple email address will be used for usernames)
- If found, we'll generate a login token that we'll store in our database
- MODX will then send an email to this user containing the login link with the token
- When the user clicks this link they are taken back to the site where a snippet runs to authenticate the token
- If successful, MODX will log the user in and redirect them to the account page
Fairly straightforward.
1. Set up our token table
The first thing we need to do is set up our table to hold our tokens. Head over to the MIGX Extra CMP and click the Package Manager tab.
Enter magiclink
as the package name then click the create package
button at the bottom of the page. Once you get the success alert, dismiss it and click on the XML Schema tab. Click load schema
, then copy and paste the schema below. When you click save schema a pop-up will ask you if you are sure, click yes
<?xml version="1.0" encoding="UTF-8"?>
<model package="magiclink" baseClass="xPDOObject" platform="mysql" defaultEngine="InnoDB" phpdoc-package="" phpdoc-subpackage="" version="1.1">
<object class="MagicToken" table="magiclink_tokens" extends="xPDOSimpleObject">
<field key="token" dbtype="varchar" phptype="string" precision="50" null="false" default="" />
<field key="userid" dbtype="int" phptype="int" precision="20" null="false" default="" />
<field key="expires" dbtype="datetime" phptype="datetime" null="true" />
</object>
</model>
Once you have saved your schema you need to create your tables. Click on the Schema tab then click parse Schema
. Next, open the Create Tables tab and click the create tables
button. MIGX will handle everything for you, from creating the database tables to generating the modal files in core/components/magiclink/
.
2. Setup the template
Now we have our table ready we can start building the login system. In your template (or chunk) create a login form with an input for an email address and a button. I've created a super simple example below:
[[!magicLogin]]
[[+message:notempty=`
<div class="alert alert-[[+type]]">[[+message]]</div>
`]]
<form action="[[~[[*id]]? &scheme=`https`]]" method="POST">
<div class="mb-3">
<label for="email" class="form-label">Email address</label>
<input type="email" class="form-control" id="email" name="email" placeholder="name@example.com" value="[[+email]]">
</div>
<div class="mb-3">
<button type="submit" class="btn btn-primary">Send Magic Link</button>
</div>
</form>
So, a simple form, but we've added a few extra bits. A snippet called magicLogin
and a few placeholders so we can return data from our snippet.
3. magicLogin Snippet
Our magic login snippet will do all the heavy lifting for us, from creating, storing, and sending the magic link details, to retrieving and evaluating the token.
The first thing we need to do is set up the snippet to use our magiclink
package and the post logic for finding the user. Create a new snippet called magicLink
and add the following code:
<?php
// Load in our magiclink package
$path = $modx->getOption('magiclink.core_path', null, $modx->getOption('core_path')) . 'components/magiclink/model/';
$magiclink = $modx->addPackage('magiclink', $path);
// Grab any scriptProperties and set default so this can be extended
$emailTpl = $modx->getOption('tpl', $scriptProperties, 'magicLinkEmail');
$expireTime = $modx->getOption('expires', $scriptProperties, '+ 15 minutes');
$loginID = $modx->getOption('loginid', $scriptProperties, 1);
$redirectID = $modx->getOption('redirect', $scriptProperties, 2);
// Set some basic variables
$placeholders = [
'message' => '', // message to set
'type' => '', // For our alert type
'email' => '' // email address so we can refill the input on error
];
// sanitize the global request
$request = $modx->sanitize($_REQUEST);
try {
// Switch our server request methods to handle both POST and GET
switch ($_SERVER['REQUEST_METHOD']) {
case "POST":
// Search for our user
$User = $modx->getObject('modUser', [
'username' => trim($request['email'])
]);
if (!is_object($User)) {
throw new Exception(sprintf("No user found with email: %s", $request['email']), 404);
}
// Lets grab this users profile so we can customise the email
$Profile = $User->getOne('Profile');
// If we have a user, we'll need to generate the token
$token = bin2hex(random_bytes(24));
// Set the token expiry time
$expires = strtotime($expireTime);
// Create the new magic link entry
$MagicToken = $modx->newObject('MagicToken', [
'token' => $token,
'expires' => $expires,
'userid' => $User->get('id')
]);
if ($MagicToken->save() === false) {
throw new Exception("Unable to create magic link", 500);
}
// Now we have a MagicToken object we'll generate a link to our login page
// In this example we are using resource ID 1 as the login page
// This will generate a link like https://www.yoursite.com/login?token=9973e0ef444fc47a7b802f2215ca36d4245da2990148bb9c
$loginLink = $modx->makeUrl($loginID, '', array('token' => urlencode($token)), 'https');
// We'll need a message to send to our user, for this we'll use our $emailTpl
$msg = $modx->getChunk($emailTpl, [
'link' => $loginLink,
'fullname' => $Profile->get('fullname'),
'expires' => $expires
]);
// Now we need to send this link to our user
$User->sendEmail($msg, [
'subject' => 'Your Magic Link'
]);
// Set our placeholders
$placeholders['message'] = "Please check your email for your magic link";
$placeholders['type'] = "success";
break;
case "GET":
// We'll code this up later
break;
}
} catch (Exception $e) {
$placeholders = [
'message' => $e->getMessage(),
'type' => 'danger',
'email' => $request['email']
];
}
// set our placeholders
$modx->setPlaceholders($placeholders);
4. Email chunk
In our snippet PHP, we reference a chunk $emailTpl = $modx->getOption('tpl', $scriptProperties, 'magicLinkEmail');
this is the message that will be sent to our user so they can log in. Create a new chunk called magicLinkEmail
and add the following HTML:
<h1>Hi [[+fullname:tag]]</h1>
<p>Here is your Magic login link to [[++site_name:tag]]. This link will expire on [[+expires:tag]]</p>
<p>
<a href="[[+link:tag]]">Magic login link</a>
</p>
5. Getting the token
Once the user has clicked on the link and is sent back to the site we need to confirm the validity of the token, check it has not expired, and then log the user in. Head back to your magicLink
snippet and add the following code:
<?php
case "GET":
// Check that we have the token set in the request, if not just return
if(empty($request['token'])) return;
// Lets grab the DB entry for this token
$Token = $modx->getObject('MagicToken', [
'token' => urldecode($request['token'])
]);
if(!is_object($Token)) {
throw new Exception("Invalid token", 403);
}
// Check that the token has not expired
$now = time();
$expires = strtotime($Token->get('expires'));
if($now > $expires) {
// Remove the token from our DB
$Token->remove();
throw new Exception("Your token has expired, please request a new link", 403);
}
// All being well we can now log the user in
$User = $modx->getObject('modUser', $Token->get('userid'));
if(!is_object($User)) {
$Token->remove();
throw new Exception("Unable to locate your user account, please try again", 404);
}
// Log the user in to the web context
$User->addSessionContext('web');
// Remove the token from the DB
$Token->remove();
// Forward the user to the dashboard
$url = $modx->makeUrl($redirectID);
$modx->sendRedirect($url);
break;
Wrapping up
That's all there is to it really, a few simple steps to create a seamless magic link experience. There are a number of ways you can improve security such as:
- Asking for a PIN when a user signs up and requesting this pin before sending the magic link
- Capture the user's IP Address and check the current IP address matches the stored one, if so offer the magic link service
- Implement 2FA
- Provide a basic level of access to the account area and require a password for anything more sensitive
In a future article, I'll be looking at different types of login systems and delving more into creating extras and custom database tables.