Building A Magic Link Passwordless Login In ColdFusion
As I build out the Dig Deep Fitness MVP (Minimum Viable Product), I'm trying to do the least amount of work that allows me to start delivering actual value. So, when it comes to user authentication, I didn't want to create a robust account management system. Instead, I ended up building a passwordless login system using magic links. I wanted to share my approach in ColdFusion in case anyone has suggestions on how to improve it or harden it against attacks.
CAUTION: I am not a security expert. This is just as much me "learning in public" as it is me sharing helpful ColdFusion techniques.
A "magic link" uses an email-based workflow in which the application emails the user a link back to the application. When clicked, the link will automatically log the user into the app, no password required. Of course, this becomes an obvious attack vector for malicious actors who are trying to authenticate under someone else's account; so, we have to come up with ways to make the magic link resistant to tampering.
To this end, I wanted my magic links to exhibit certain properties:
The magic link should have a relatively short expiration date.
The magic link should only work once, preventing a replay attack.
The magic link should be cryptographically signed using a private key in order to make sure that the link has not been tampered with.
The magic link signing should include some sort of private salt in the signing in order to reduce the chances that the private signing key can be deduced for any given magic link example.
This is less a property of the magic link itself and more a property of the application, but there should be some sort of rate limiting applied to both the requesting of and verification of magic links. This will help prevent both spam (outbound) and brute forcing (inbound). This is beyond what I'll discuss in this post.
As I starting playing around with code for the magic links / passwordless login, it occurred to me that most of these behaviors could be achieved if I built the authentication workflow on top of a One-Time Token system. A one-time token system is, essentially, a fancy key-value store in which the key (token) can only ever be accessed once; and, will automatically self-destruct the first time it is read.
From a high level, we can then create a magic link that includes a short-lived one-time token; and, cryptographically sign the magic link in order to make sure that the given email address and one-time token have not been altered. This would take care of the "one-time use" and the "expiration date" behaviors; but, we're still missing the "private salt" aspect.
To implement a private salt for each magic link, we can build our one-time token service to store an arbitrary "value". In fact, we can take that a step further and include a "passcode" for our token retrieval as well. Here's the snippet of code that creates a one-time token:
ASIDE: In this code, the
tokens
collection is an instance of Java'sConcurrentHashMap
. In a future version of this ColdFusion application, this would likely be replaced with Redis.
component {
/**
* I create and return the next random token. An optional passcode can be associated
* with the token in order to help prevent malicious tampering.
*/
public string function createToken(
required numeric ttlInMinutes,
string passcode = "",
any value = ""
) {
var token = secureRandom.getToken( 32 );
var expiresAt = now()
.add( "n", ttlInMinutes )
;
var payload = {
expiresAt: expiresAt,
passcode: passcode,
value: value
};
tokens.put( token, payload )
return( token );
}
}
The "passcode" can be used to tie the one-time token to a specific user such that a malicious actor can't transfer a one-time token from one context to another (ex, one magic link to another). When the one-time token is later accessed during login verification, the same "passcode" has to be provided in order to read the token value. Here's a snippet of code that reads the given one-time token:
component {
/**
* I MAYBE get the value associated with the given token and (optional) passcode. This
* will IMPLICITLY DELETE THE token.
*/
public struct function maybeGetToken(
required string token,
string passcode = ""
) {
// NOTE: !! Locking / synchronization !! code has been omitted for readability.
var results = {
exists: false
};
if ( ! tokens.containsKey( token ) ) {
return( results );
}
// GET AND SELF-DESTRUCT THE TOKEN SO IT CAN'T BE USED AGAIN.
var payload = tokens.get( token );
tokens.remove( token );
if ( compare( payload.passcode, passcode ) ) {
return( results );
}
if ( payload.expiresAt < now() ) {
return( results );
}
results.exists = true;
results.value = payload.value;
return( results );
}
}
As you can see, the calling context has to provide both the one-time token and the (optional) passcode in order to get read the token value. In our magic link / passwordless login workflow, the "passcode" will be the user's email address and the "value" will be the private salt used in the signing.
Here's the code snippet that then generates the magic link and sends it to the user:
component {
/**
* I initiate a login request workflow for the given email.
*/
public void function requestLogin( required string email ) {
// NOTE: Rate-limiting code omitted for readability.
// NOTE: The expiration of the one-time token will IMPLICITLY create an overall
// expiration for the login URL itself.
var salt = secureRandom.getToken( 16 );
var token = oneTimeTokenService.createToken( 15, email, salt );
var signature = generateLoginRequestSignature( email, token, salt );
var loginUrl = (
"#site.url#/index.cfm" &
"?event=auth.verifyLogin" &
"&email=#encodeForUrl( email )#" &
"&token=#encodeForUrl( token )#" &
"&signature=#encodeForUrl( signature )#"
);
emailService.sendLoginRequest( email, loginUrl );
}
}
As you can see, this authentication workflow code is doing the following:
Generates a cryptographically secure random 16-byte value (using the
sha1prng
under the hood). This is our private salt.Generates a short-lived (15-minute) one-time token using the user's email address as the "passcode" and storing the random salt as the "value". This will make the signatures random and tightly couple each token to a specific user.
Generates a cryptographically secure signature of the (
email
,token
,salt
) tuple (using thehmacsha512
hashing algorithm under the hood).
This magic link then gets emailed to the user where they will have 15-minutes to click on it. And, when they do, we will pull the various query-string parameters out of the URL and validate them against our one-time token service and signature generation:
component {
/**
* I verify the login request for the given email. This will create a new user if the
* email is not recognized.
*/
public void function verifyLogin(
required string email,
required string token,
required string signature
) {
// NOTE: Rate-limiting code omitted for readability.
var maybeToken = oneTimeTokenService.maybeGetToken( token, email );
if ( ! maybeToken.exists ) {
throw(
type = "App.Authentication.VerifyLogin.Expired",
message = "Login verification token has expired."
);
}
var salt = maybeToken.value;
var expectedSignature = generateLoginRequestSignature( email, token, salt );
if ( compare( signature, expectedSignature ) ) {
throw(
type = "App.Authentication.VerifyLogin.SignatureMismatch",
message = "Login verification signature does match expected signature.",
detail = "Signature: [#signature#], Expected signature: [#expectedSignature#]."
);
}
var maybeUser = userService.maybeGetUserByEmail( email );
if ( maybeUser.exists ) {
var user = maybeUser.value;
} else {
var userID = userService.createUser( email );
var user = userService.getUser( userID );
}
authService.startSession( user.id, requestMetadata.getIpAddress() );
}
}
So, to reiterate the behaviors that are being captured in this workflow:
We are using both the
token
and theemail
(passcode) to access the one-time token value (our salt). This alone implements our expiration date and our one-time use constraints. And, it makes sure that a one-time token for one user's email can't be transferred into another person's magic link (the passcode mismatch would fail).We use the salt returned by our token exchange, along with the
token
andemail
(and a private key, not yet shown) to generate a secure hash proving that the URL has not been tampered with.
Once the magic link has been validated, we then create the user record it there is none associated with the given email
, and start the user's session.
And kablamo! The user is logged-in and ready to started lifting heavy weights!
I believe that magic links / passwordless logins provide a pretty good user experience (UX), assuming that the emails can be delivered in a timely manner. I use Postmark for my email delivery, and they've always been an awesome partner! I'm also pretty please with how this came out. From a security standpoint, I believe I've checked all the boxes; but, if anyone has any suggestions, I'd love to know!
Full Code Examples
While the above snippets get at the heart of what I'm trying to do with the magic links / passwordless logins, it leaves much detail to the imagination. What follows is my (mostly) full code as of this writing. I've left out some details that might just add noise.
As discussed above, this whole workflow is essentially powered by a one-time token service. Note that all of the CFProperty
tags in my ColdFusion code are powered by a simple ColdFusion dependency injection (DI) framework that I built the other day.
NOTE: In this ColdFusion component, I'm using nested locks to synchronize background clean-up of expired tokens. This is an implementation detail that unfortunately adds some noise here. If I were using something like Redis to store the token, these locks could be removed and the whole TTL (Time To Live) aspect could be off-loaded).
component
output = false
hint = "I provide methods for generating and verifying one-time tokens."
{
// Define properties for dependency-injection.
property name="secureRandom" ioc:type="lib.util.SecureRandom";
/**
* I initialize the one-time token service.
*/
public void function $init() {
variables.tokens = createObject( "java", "java.util.concurrent.ConcurrentHashMap" )
.init()
;
}
// ---
// PUBLIC METHODS.
// ---
/**
* I create and return the next random token. An optional passcode can be associated
* with the token in order to help prevent malicious tampering.
*/
public string function createToken(
required numeric ttlInMinutes,
string passcode = "",
any value = ""
) {
purgeExpiredTokens();
var token = secureRandom.getToken( 32 );
var expiresAt = now()
.add( "n", ttlInMinutes )
;
var payload = {
expiresAt: expiresAt,
passcode: passcode,
value: value
};
tokens.put( token, payload )
return( token );
}
/**
* I test to see if the given token is valid. This will IMPLICITLY DELETE THE token.
*/
public boolean function isTokenValid(
required string token,
string passcode = ""
) {
return( maybeGetToken( argumentCollection = arguments ).exists );
}
/**
* I MAYBE get the value associated with the given token and (optional) passcode. This
* will IMPLICITLY DELETE THE token.
*/
public struct function maybeGetToken(
required string token,
string passcode = ""
) {
var results = {
exists: false
};
lock
name = "OneTimeTokenService.PurgeTokens"
type = "readonly"
timeout = 5
{
lock
name = "OneTimeTokenService.isTokenValid.#token#"
type = "exclusive"
timeout = 5
{
if ( ! tokens.containsKey( token ) ) {
return( results );
}
var payload = tokens.get( token );
tokens.remove( token );
if ( compare( payload.passcode, passcode ) ) {
return( results );
}
if ( payload.expiresAt < now() ) {
return( results );
}
results.exists = true;
results.value = payload.value;
return( results );
} // END: Token lock.
} // END: Purge lock.
}
/**
* I test the given token to see if it is valid; and, if not, throw an error. This will
* IMPLICITLY DELETE THE token.
*/
public void function testToken(
required string token,
string passcode = ""
) {
if ( ! isTokenValid( argumentCollection = arguments ) ) {
throw(
type = "App.OneTimeToken.Invalid",
message = "Token is no longer valid."
);
}
}
// ---
// PRIVATE METHODS.
// ---
/**
* I purge any stored token which is expired.
*/
private void function purgeExpiredTokens() {
lock
name = "OneTimeTokenService.PurgeTokens"
type = "exclusive"
timeout = 5
{
var timestamp = now();
var keys = tokens.keys();
while ( keys.hasNext() ) {
var token = keys.next();
if ( tokens.get( token ).expiresAt < timestamp ) {
tokens.remove( token );
}
}
}
}
}
Under the hood, this one-time token service is using my SecureRandom.cfc
component to generate cryptographically secure random values. Under the hood, this is basically just a repeated call to randRange()
using the sha1prng
algorithm:
component
output = false
hint = "I provide utility methods for securely generating random values."
{
/**
* I generate a cryptographically secure random integer between the two values,
* inclusive.
*
* NOTE: This method lays the groundwork for the rest of the methods in this component.
*/
public numeric function getInt(
required numeric minValue,
required numeric maxValue
) {
// CAUTION: Using the SHA1PRNG algorithm is what makes this entire component a
// cryptographically secure system. DO NOT CHANGE THIS ALGORITHM!
return( randRange( minValue, maxValue, "sha1prng" ) );
}
/**
* I generate a cryptographically secure random token of the given length. The token
* is composed of URL-friendly characters.
*/
public string function getToken( required numeric tokenLength ) {
// The set of character inputs that we can compose the random token.
var charset = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
var charsetCount = charset.len();
var letters = arrayNew( 1 )
.resize( tokenLength )
;
for ( var i = 1 ; i <= tokenLength ; i++ ) {
letters[ i ] = charset[ getInt( 1, charsetCount ) ];
}
return( letters.toList( "" ) );
}
}
The one-time token service and the secure-token generation are just the foundations of the passwordless / magic link login. Ultimately, it's my AuthWorkflow.cfc
ColdFusion component that ties it all together.
In this code, the loginRequestKey
is a Base64-encoded, 128-byte random value. I chose this because this is the length that Microsoft recommends for the HMacSha512 algorithm. I hope that this recommendation is general purpose, and not tied to a Microsoft-specific implementation.
I generated the random value using the openssl
command:
openssl rand -base64 128
ColdFusion's hmac()
function can accept either a String or a Binary value as the key; so, I'm using binaryDecode()
to convert the private key from Base64 to binary.
component
output = false
hint = "I provide workflow methods pertaining to authentication."
{
// Define properties for dependency-injection.
property name="authService" ioc:type="lib.AuthService";
property name="emailService" ioc:type="lib.EmailService";
property name="loginRequestKey" ioc:get="config.keys.loginRequest";
property name="oneTimeTokenService" ioc:type="lib.OneTimeTokenService";
property name="requestMetadata" ioc:type="lib.RequestMetadata";
property name="secureRandom" ioc:type="lib.util.SecureRandom";
property name="site" ioc:get="config.site";
property name="userService" ioc:type="lib.model.user.UserService";
property name="userValidation" ioc:type="lib.model.user.UserValidation";
/**
* I initialize the auth workflow.
*/
public void function $init() {
variables.loginRequestKeyBinary = binaryDecode( loginRequestKey, "base64" );
}
// ---
// PUBLIC METHODS.
// ---
/**
* I initiate a login request workflow for the given email.
*/
public void function requestLogin( required string email ) {
email = userValidation.testEmail( email );
// ... rate limiting code omitted ...
// NOTE: The expiration of the one-time token will IMPLICITLY create an overall
// expiration for the login URL itself.
var salt = secureRandom.getToken( 16 );
var token = oneTimeTokenService.createToken( 15, email, salt );
var signature = generateLoginRequestSignature( email, token, salt );
var loginUrl = (
"#site.url#/index.cfm" &
"?event=auth.verifyLogin" &
"&email=#encodeForUrl( email )#" &
"&token=#encodeForUrl( token )#" &
"&signature=#encodeForUrl( signature )#"
);
emailService.sendLoginRequest( email, loginUrl );
}
/**
* I verify the login request for the given email. This will create a new user if the
* email is not recognized.
*/
public void function verifyLogin(
required string email,
required string token,
required string signature
) {
// ... rate limiting code omitted ...
var maybeToken = oneTimeTokenService.maybeGetToken( token, email );
if ( ! maybeToken.exists ) {
throw(
type = "App.Authentication.VerifyLogin.Expired",
message = "Login verification token has expired."
);
}
var salt = maybeToken.value;
var expectedSignature = generateLoginRequestSignature( email, token, salt );
if ( compare( signature, expectedSignature ) ) {
throw(
type = "App.Authentication.VerifyLogin.SignatureMismatch",
message = "Login verification signature does match expected signature.",
detail = "Signature: [#signature#], Expected signature: [#expectedSignature#]."
);
}
var maybeUser = userService.maybeGetUserByEmail( email );
if ( maybeUser.exists ) {
var user = maybeUser.value;
} else {
var userID = userService.createUser( email );
var user = userService.getUser( userID );
}
authService.startSession( user.id, requestMetadata.getIpAddress() );
}
// ---
// PRIVATE METHODS.
// ---
/**
* I generate the signature used with the login request workflow.
*/
private string function generateLoginRequestSignature(
required string email,
required string token,
required string salt
) {
return( hmac( "#salt#~#email#~#token#", loginRequestKeyBinary, "hmacsha512" ).lcase() );
}
}
There's still some stuff that is left to the imagination; but, this is the core of how my magic link / passwordless login system works. Once again, if anyone has any suggestions, please let me know!
Want to use code from this post? Check out the license.
Reader Comments
A major downside with my current implementation is that the tokens are stored in memory. Which means, if the app gets reset or the server restarts, the tokens all die. Moving the tokens to an external storage, like Redis, would solve this problem.
But, I'm in the MVP stage right now, so none of this is really a concern for me.
I've been using a UDF that I believe provides a similar solution and it relies on cachePut/cacheGet so that no data is passed in the token. It automatically expires on the server after the duration has passed. This approach is only practical on a monolith web application unless the cache is extended and available to multiple servers.
I shared it as a gist here:
https://gist.github.com/JamoCA/fd43c189379196b6a52884affea3ad51
(If passed an object, it returns a UUID. If passed a UUID, it returns an either an object or an empty object if not found or expired.)
@James,
For whatever reason, the whole in-built caching functionality in ColdFusion is a real blind-spot for me. In my formative years, I just never looked into it; and, in the last 13-years, I've been in a multi-machine architecture. That said, I think one could easily swap-in Redis and use the same exact workflow. In fact, I wouldn't be surprised if ColdFusion (or maybe Lucee) had a way to seamless use Redis for caching under the hood. I think you can do that with session management (but, also something I'm not as versed with).
Post A Comment — ❤️ I'd Love To Hear From You! ❤️
Post a Comment →