Enable 2-Step Verification in Strapi using Twilio

Feras Allaou
Managing Partner


Strapi is a headless CMS built using Nodejs, it allows developers to create custom content types easily, add validation rules and handle User Authentication out of the box, so Mobile developers can simply build an API for their Apps with a CMS to filter data easily. However, the Buzz killer here is that Strapi doesn’t support 2-Step Verification, although this feature was added to the roadmap since 2018.

Fortunately, Strapi was built as a collection of packages so it’s customizable using what is known as extensions. This means that we can choose any package and customize it. In our situation, we want to customize User Auth, so our targeted package is users-permissions.

Strapi’s Auth

Currently, Strapi handles User Auth using two controllers, User.js & Auth.js which can be located inside node_modules/strapi-plugin-users-permissions/controllers/.

Username and Email should be unique, and if we open User.js and navigate to create method, we can see that it’s checking whether the same username or Email are already taken to halt the registration process.

                                
const userWithSameUsername = await strapi
.query('user', 'users-permissions')
.findOne({ username });
// And
const userWithSameEmail = await strapi.query('user', 'users-permissions').findOne({ email });
                                
                            

Override

What we want to achieve here is to have a verification code sent to the user when registering as this is so handy for Mobile Developers. So first, we need to add two new fields to the User entity using Strapi’s dashboard. Let’s create token & phone fields with type String. Now, let’s dive into the coding part.

Let’s create an extension for the users-permissions package. In the root folder create extensions/users-permissions/controllers. Then we need to create a new file named User.js to override the original one.

Before diving too much I’ve to clarify one thing, by default Strapi registers some routes such as create, update, me, …etc, So if you don’t want to change their behavior just don’t include them in your new User.js. But since we are overriding the create method, we have to include it in our new User.js extension.

                                
const twilio = {
id: "YOUR_TWILIO_ACCOUNT_ID",
token: "TWILIO_ACCOUNT_TOKEN",
phone: "TWILIO_PHONE_NO"
}

const smsClient = require('twilio')(twilio.id, twilio.token);

async create(ctx) {

const { phone, username } = ctx.request.body;

if (!phone) return ctx.badRequest('missing.phone');
if (!username) return ctx.badRequest('missing.username');


const userWithThisNumber = await strapi
    .query('user', 'users-permissions')
    .findOne({ phone });

if (userWithThisNumber) {
    return ctx.badRequest(
    null,
    formatError({
        id: 'Auth.form.error.phone.taken',
        message: 'Phone already taken.',
        field: ['phone'],
    })
    );
}

const token = Math.floor(Math.random() * 90000) + 10000;

const user = {
        username,
    phone,
    provider: 'local',
    token
};

const advanced = await strapi
    .store({
    environment: '',
    type: 'plugin',
    name: 'users-permissions',
    key: 'advanced',
    })
    .get();

const defaultRole = await strapi
    .query('role', 'users-permissions')
    .findOne({ type: advanced.default_role }, []);

user.role = defaultRole.id;


try {
    const data = await strapi.plugins['users-permissions'].services.user.add(user);
    await smsClient.messages.create({
    to: phone,
    from: twilio.phone,
    body: `Your verification code is ${token}`
    })
    ctx.created(sanitizeUser(data));
} catch (error) {
    ctx.badRequest(null, formatError(error));
}
}

                                    
                            

As you can see from the code snippet above, we overrode the create method accepting only phone & username from the user, and then we check if the same number is already used, if not, we just create the token and then send it to the user’s phone after creating the user Object & storing it inside our DB.

Try to make a POST call to localhost:1337/users with phone and username in the body and things should be working as expected. And just one note, we can use the same logic inside Auth.js when overriding register method, But since we are in control of the flow, we can make our App call any endpoint we want as long as it’s doing the job.

Let’s create a new method to verify the user’s account and send a token to be used in future API calls.

                                
async verifyAccount(ctx) {

    const { phone, token } = ctx.request.body;

    if (!phone) return ctx.badRequest('missing.phone');
    if (!token) return ctx.badRequest('missing.token');


    const verifyUserCode = await strapi
    .query('user', 'users-permissions')
    .findOne({ phone, token });

    if (!verifyUserCode) {
    return ctx.badRequest(
        null,
        formatError({
        id: 'Auth.form.error.code.invalid',
        message: 'Invalid Code or Number',
        field: ['phone'],
        })
    );
    }

let updateData = {
    token: '',
    confirmed: true
    };


    const data = await strapi.plugins['users-permissions'].services.user.edit({ id }, updateData);
    const jwt = strapi.plugins['users-permissions'].services.jwt.issue({
    id: data.id,
    })
    ctx.send({ jwt, user: sanitizeUser(data) });
}

                                
                            

Now let’s register the new method and enable it. First, inside extensions/users-permissions we need to create config folder with routes.json file

                                
{
    "routes": [
    {
        "method": "POST",
        "path": "/verify",
        "handler": "User.verifyAccount",
        "config": {
        "policies": []
        }
    }
    ]
}
                                
                            

Last, but not least, from Starpi’s dashboard we navigate to Roles & Permissions -> Public ->Users-Permissions and then we click on VerifyAccount under the User entity, then we hit Save. Now we can make a POST call to localhost:1337/users-permissions/verify with phone & token to check if the user got it right, and if that is the case, we will have a token to be used in our calls to any protected route.

If you got an error about email field being required, please modify User.settings.json which is located inside extensions/users-permissions so the email key will be like this

                                
"email": {
    "type": "email",
    "minLength": 6,
    "configurable": false
    }