How to Implement a Magic Link in CakePHP Using Twilio

October 16, 2024
Written by
Popoola Temitope
Contributor
Opinions expressed by Twilio contributors are their own
Reviewed by

How to Implement a Magic Link in CakePHP Using Twilio

A magic link is an authentication method that allows users to log in to an application or website — without using a password. Instead, the user provides their phone number or email address, and the system sends them a unique, time-sensitive link to that phone number or email address. When the user clicks on the link, they are then automatically logged in.

In this tutorial, you will learn how to implement and send a magic login link to users via SMS in a CakePHP application.

Requirements

To complete this tutorial, you will need the following:

Set up a new CakePHP project

Let's get started by creating a new CakePHP project. To do this, open your terminal and navigate to the directory where you create your PHP projects. Then, run the commands below to create the project and change into the new project directory.

composer create-project --prefer-dist cakephp/app:~5.0 magic_link_login_app
cd magic_link_login_app

Once the project is created, you will be prompted with "Set Folder Permissions? (Default to Y) [Y, n]?". Answer with "Y".

Retrieve your Twilio access credentials

Now, let’s retrieve our Twilio access credentials by logging into your Twilio Console dashboard. You will find them (the Account SID and Auth Token) under the Account Info section, as shown in the screenshot below.

Twilio access tokens

Set the credentials as environment variables

Let’s now add the Twilio credentials as environment variables. To do this, you will first need to create a .env file using the command below.

cp config/.env.example config/.env

Next, open the project directory in your preferred code editor or IDE and navigate to the config folder. Inside the folder, open the .env file and add the following three variables, replacing the placeholders with their corresponding values.

export TWILIO_ACCOUNT_SID="<twilio_account_sid>"
export TWILIO_AUTH_TOKEN="<twilio_auth_token>"
export TWILIO_NUMBER="<twilio_phone>"

Now, you need to enable the josegonzalez\Dotenv library to load the environment variable into the application. To do this, open the bootstrap.php file inside the config folder and uncomment the code below.

if (!env('APP_NAME') && file_exists(CONFIG . '.env')) {
    $dotenv = new \josegonzalez\Dotenv\Loader([CONFIG . '.env']);
    $dotenv->parse()
        ->putenv()
        ->toEnv()
        ->toServer();
}

Install the Twilio PHP Helper Library

To simplify how the CakePHP application interacts with Twilio's Programmable Messaging API (allowing it to send a magic login link to users via SMS), you need to install Twilio's PHP Helper Library. To install the library, run the command below.

composer require twilio/sdk

Connect to the database

Let’s connect the application to the database. To do this, navigate to the config folder and open the app_local.php file. Inside the file, locate the default subsection of the Datasource section. In this section, replace the values for the host, username, password, and database settings with your corresponding MySQL database values, as shown in the screenshot below.

Connect to the Database

Now, start the MySQL database server and create a new database named register_users.

Create the database schema

CakePHP allows developers to easily create database tables using migrations, which generate migration files which define the database schema. To generate a migration file to define the users table, run the command below.

bin/cake bake migration users

Now, let’s define the table properties. To do that, navigate to the config/Migrations folder and open the generated migration file that ends with _Users.php. Then, update the change() function with the code below.

public function change(): void
{
    $table = $this->table('users');
    $table->addColumn('fullname', 'string', [
        'limit' => 255,
        'null' => true,
    ]);
    $table->addColumn('phone_no', 'string', [
        'limit' => 15,
        'null' => true,
    ]);
    $table->addColumn('password', 'string', [
        'limit' => 255,
        'null' => true,
    ]);
    $table->addColumn('login_code', 'string', [
        'limit' => 255,
        'null' => true,
    ]);
    $table->addColumn('login_code_time', 'string', [
        'limit' => 255,
        'null' => true,
    ]);
    $table->create();
}

To complete the database migration and generate a model file for the application, run the commands below.

bin/cake migrations migrate
bin/cake bake model users

Next, start the CakePHP application by running the following command.

bin/cake server

After starting the development server, open http://localhost:8765 in your browser. You should see the application's default page, as shown in the screenshot below.

CakePHP default page

If you see the Security.salt error notice, you can clear it by adding the environment variable below to the .env file.

export SECURITY_SALT="<new-random-string-generated>"

Replace the <new-random-string-generated> placeholder with a random value of at least 32 characters.

Create the application controller

Now, let’s generate a controller file for the application, named authentication, run the command below.

bin/cake bake controller authentication --no-actions

Next, let’s add the application logic to the authentication controller. To do that, navigate to the src/Controller folder and open the AuthenticationController.php file. Then, update the file with the following code.

<?php

declare(strict_types=1);

namespace App\Controller;

use App\Model\Table\UsersTable;
use Cake\Utility\Security;
use DateTime;
use Twilio\Rest\Client;

class AuthenticationController extends AppController
{
    public function register()
    {
        if ($this->request->is('post')) {
            $fullname = $this->request->getData('fullname');
            $phone_no = $this->request->getData('phone_no');
            $password = $this->request->getData('password');
            if (empty($fullname) || empty($phone_no) || empty($password)) {
                $this->Flash->error(__('Please fill in all the required fields.'));
            } else {
                $usersTable = new UsersTable();
                $user = $usersTable->findByPhone_no($phone_no)->first();
                if ($user) {
                    $this->Flash->error(__('Username already exists.'));
                } else {
                    $userData = $this->request->getData();
                    $user = $usersTable->newEmptyEntity();
                    $user = $usersTable->patchEntity($user, $userData);
                    if ($usersTable->save($user)) {
                        $this->Flash->success(__('Registration successful.'));
                        return $this->redirect(['action' => 'login']);
                    }
                }
            }
        }
    }

    public function login()
    {
        if ($this->request->is('post')) {
            $phone_no = $this->request->getData('phone_no');
            $usersTable = $this->getTableLocator()->get('Users');
            $user = $usersTable->findByPhone_no($phone_no)->first();
            if ($user) {
                $loginCode = Security::hash(Security::randomBytes(32), 'sha256', true);
                $user->login_code = $loginCode;
                $loginCodeTime = (new \DateTime())->modify('+5 minutes');
                $user->login_code_time = $loginCodeTime->format('Y-m-d H:i:s');
                $usersTable->save($user);
                $twilioSid = env('TWILIO_ACCOUNT_SID');
                $twilioToken = env('TWILIO_AUTH_TOKEN');
                $twilioNumber = env('TWILIO_NUMBER');
                $app_domain = env('APP_DOMAIN');
                if (!$twilioSid || !$twilioToken || !$twilioNumber || !$app_domain) {
                    $this->Flash->error(__('Twilio configuration is missing. Please check your environment variables.'));
                    return;
                }
                $twilio = new Client($twilioSid, $twilioToken);
                $message = "Your magic login link: ".$app_domain."/magic_link?logincode=" . $loginCode;
                    $twilio->messages->create(
                        $phone_no,
                        [
                            'from' => $twilioNumber,
                            'body' => $message,
                        ]
                    );
                    $this->Flash->success(__('A magic login link has been sent to your phone via SMS.'));
            } else {
                $this->Flash->error(__('Phone number not found.'));
            }
        }
    }

    public function magic()
    {
        if ($this->request->is('get')) {
            $loginCode = $this->request->getQuery('logincode');
            if (!$loginCode) {
                $this->Flash->error(__('Invalid login magic link.'));
                return $this->redirect(['action' => 'login']);
            }
            $usersTable = $this->getTableLocator()->get('Users');
            $user = $usersTable->findByLogin_code($loginCode)->first();
            if (!$user) {
                $this->Flash->error(__('Invalid login code.'));
                return $this->redirect(['action' => 'login']);
            }
            $loginCodeTime = new \DateTime($user->login_code_time);
            $currentTime = new \DateTime();
            if ($loginCodeTime > $currentTime) {
                $this->getRequest()->getSession()->write('fullname', $user->fullname);
                return $this->redirect(['action' => 'profile']);
            } else {
                $this->Flash->error(__('Login code has expired.'));
                return $this->redirect(['action' => 'login']);
            }
        }
        return $this->redirect(['action' => 'login']);
    }

    public function profile() {
        if ($this->getRequest()->getSession()->check('fullname')) {
            $user= $this->getRequest()->getSession()->read('fullname');
            $this->set(compact('user'));
        } else {
            return $this->redirect(['action' => 'login']);
        }
    }
}

Here is the break down of the controller code above:

  • The register() method handles the registration form, stores the user's data in the database, and then redirects the user to the login page if the registration is successful.
  • The login() method handles user login authentication by sending a magic login link to the user via SMS.
  • The magic() method validates the magic login link by checking whether the link is valid or expired. If the link is valid, the user is automatically logged in and directed to the profile page.
  • The profile() method displays the user's profile page if the user is logged in. If the user is not logged in, they are redirected to the login page.

Create the application's templates

Let’s now implement the application's user interface by creating the template files for the authentication controller’s methods. To create the controller's template files, navigate to the templates folder and create a new folder named Authentication. Inside the Authentication folder, create the following files:

  • register.php
  • login.php
  • profile.php

Now, inside the register.php file, add the code below.

<div class="users form">
    <?= $this->Form->create() ?>
    <h2>Create a new account.</h2>
    <fieldset>
        <?php
            echo $this->Form->control('fullname');
            echo $this->Form->control('phone_no');
            echo $this->Form->control('password', ['type' => 'password']);
        ?>
    </fieldset>
    <?= $this->Form->button(__('Register')) ?>
    <?= $this->Form->end() ?>
</div>
<p>Already have an account? <a href="login">Login here.</a>.</p>

Inside the login.php file, add the code below.

<div class="users form">
    <?= $this->Form->create() ?>
    <h2>Login</h2>
    <fieldset>
        <?php
            echo $this->Form->control('phone_no');
        ?>
    </fieldset>
    <?= $this->Form->button(__('send Magic login link')) ?>
    <?= $this->Form->end() ?>
</div>
<p>New member? <a href="register">Create a new account here. </a></p>

Lastly, inside the profile.php, add the code below.

<h2>Profile</h2>
<h3>Welcome, <?= h($user) ?></h3>

Update the application's routing table

To set up the application's routes, navigate to the config folder and open the routes.php file. Inside the file, locate $routes->scope() and add the following code there, before $builder->fallbacks().

$builder->connect('/register', ['controller' => 'Authentication', 'action' => 'register', 'Registration Page']);
$builder->connect('/login', ['controller' => 'Authentication', 'action' => 'login', '   Login Page']);
$builder->connect('/magic_link', ['controller' => 'Authentication', 'action' => 'magic', 'Magic Login Page']);
$builder->connect('/profile', ['controller' => 'Authentication', 'action' => 'profile', 'Magic Login Page']);

Make the application accessible on the internet

Finally, let’s make the application accessible over the internet, allowing us to use the magic login link on any device. Using Ngrok, open a new terminal tab (or session) and run the command below.

ngrok http http://localhost:8765

The command generates a Forwarding URL in the terminal, as shown in the screenshot below.

generating forwarding URL using Ngrok

Next, add the Forwarding URL to the environment variables. To do that, navigate to the config folder and open the .env file. Inside the file, add the environment variables below, replacing <forwarding_url> placeholder with the Forwarding URL generated by ngrok.

export APP_DOMAIN=<forwarding_url>

Test the application

To test the application, open http://localhost:8765/register in your browser and register a new account, as shown in the screenshot below.

Test the Application

After creating a new account, you will be redirected to the login page where you can log in using a magic login link, as shown in the screenshot below.

Test the Application

Once you enter your login phone number and click on SEND MAGIC LOGIN LINK, you will receive your magic login link via SMS, as shown in the screenshot below.

When you click on the received link, you will automatically log in to your profile page, as shown in the screenshot below.

Magic Link provides a seamless and secure authentication system that enhances the user experience by allowing users to log in without a password. In this tutorial, we explored how to implement a magic login link system in a CakePHP application using SMS.

Popoola Temitope is a mobile developer and a technical writer who loves writing about frontend technologies. He can be reached on LinkedIn.

The link icon in the tutorial's main image was created by Freepik on Flaticon.