Two-Factor Authentication Using SMS
Implement two-factor authentication from a browser using SMS with FreeClimb
Two-factor authentication (2FA) is quickly becoming the standard for securing sensitive information. Hackers are using more sophisticated attacks which are harder and harder to thwart. One way to prevent these attacks is to implement a 2FA system which can verify the identity of your user.
In this how-to guide, we will guide you through implementing a 2FA system using FreeClimb. This system will perform the following actions:
- Get a phone number from the user using a webpage
- Send a verification code to the user's phone
- Get the verification code from the user
- Expire the verification code after a set amount of time
- Verify the code
You're ready for this how-to guide if you have the following:
A FreeClimb account
A registered application with a named alias
A configured FreeClimb number
Trial accounts: A verified number
Your language and tools:
Step 1: App set-up
Get dependencies
To start create a new directory for the project. Within this directory create a file named package.json
and add the following content:
{
"name": "nodejs-2fa-sample-app",
"version": "1.0.0",
"description": "Sample app to send 2fa verification code to user",
"main": "index.js",
"license": "MIT",
"dependencies": {
"@freeclimb/sdk": "^1.1.1",
"body-parser": "^1.19.0",
"dotenv": "^8.2.0",
"dotenv-safe": "^8.2.0",
"express": "^4.17.1",
"express-handlebars": "^5.2.0"
},
"scripts": {
"start": "DEBUG=express:* node ."
}
}
These dependencies can be installed from the command line by using the following command:
yarn install
Set environment variables
In order to authenticate your requests with the FreeClimb platform, we need to include your API credentials (account ID and API key) in every request. Your account ID and API key can be found in the FreeClimb dashboard.
Why put API credentials in environment variables?
If you were to push your code to a public repository, those credentials would be public and an attacker could steal your account. To prevent this we create environment variables with your API credentials and import them into your code using dotenv-safe.
This sample app uses dotenv-safe to read in your credentials, and anything else you save, as environment variables. As required by dotenv-safe, before creating our .env
file we must create a .env.example
file that outlines the environment variables that will need to be present in the actual .env
file.
You must include both a
.env.example
file and a.env
file in order for dotenv-safe to work properly.
To do so, create a file in your project's directory named .env.example
and add the following content:
ACCOUNT_ID=
API_KEY=
FC_PHONE_NUMBER=
PORT=
Once you've created your .env.example
file, create a .env
file in the root directory of your repo. Make sure to add the file to your .gitignore
file. Add your account ID and API key to your .env
file.
If you haven't already, make sure to assign a registered application to the configured FreeClimb number you will be using for this sample application. This configured number will be included in environment variables.
Additionally, we will include a port number. This will allow you to specify the port on which the app will run. For example, if you were to enter PORT=3000
to view the app, you would direct your browser to http://localhost:3000
.
Your .env
file should look as follows:
ACCOUNT_ID="YOUR-ACCOUNT-TOKEN"
API_KEY="YOUR-API-KEY"
FC_PHONE_NUMBER="+15555550010"
PORT="YOUR-PORT-NUMBER"
Save your files and continue to the next step.
Step 2: Create the express server
The server will render our HTML templates and provide endpoints where user input can be captured. Create an index.js
file in your project directory and import the needed dependencies:
const env = require('dotenv-safe')
const express = require('express')
const bodyParser = require('body-parser')
const handlebars = require('express-handlebars')
// uncomment the lines below when you are ready to add in FreeClimb API functions in Step 6
// const msg = require('./messenger')
// const verification = require('./verification')
Inside the server file, load dotenv-safe and create/configure the express application:
env.config()
const app = express()
app.engine('handlebars', handlebars({ defaultLayout: 'main' }))
app.set('view engine', 'handlebars')
app.use(bodyParser.urlencoded({ extended: true }))
const port = process.env.PORT || 3000
Then, create a simple endpoint for incoming GET
requests to /
and set the app to listen on your desired port:
app.get('/', (req, res) => {
res.status(200).send('hello world')
})
const server = app.listen(port, () => {
console.log(`running app on port ${port}`)
})
Execute the following command in the command line in order to finish setting up your Express server:
yarn start
Now open a browser and head to localhost://{your-port-from-.env}
. You should see the text "hello world."
Congrats you've set up an Express server with Node!
Step 3: Set up your webpages & templates
To separate the application logic in the code from our webpages we use express-handelbars. This allows us to remove any complicated Javascript from our HTML forms. To setup the templates we'll be using in this app, create a directory named "views" within your project. Then, inside the "views" directory create another directory named "layouts."
Create a file called main.handlebars
inside the "layouts" directory with the following content. This is a basic layout that will contain all the pages for this application:
<!doctype html>
<html>
<head>
<title>Freeclimb 2FA Sample</title>
</head>
<body>
<h1>FreeClimb 2FA Sample</h1>
{{{body}}}
</body>
</html>
Get a user's phone number
The first step of the 2FA process is obtaining the user's phone number. To do this create an HTML template with the following content in your "views" directory named inputPhone.handlebars
. This template contains only one input and one button for form submission as well as a block that can be used to conditionally render error messages (denoted by the {{#if error }}
). We'll use this if we need to re-render the form in the event the user enters an invalid phone number.
On submission of this form, a POST
request will be issued to the /send-verification
endpoint containing the user-entered phone number (shown in the form tag as method="post" action="/send-verification"
):
<p>Please enter your phone number in E.164 format (e.g. +1234567891)</p>
<form method="post" action="/send-verification">
<input type="tel" name="number" />
<input type="submit" value="Send code" />
</form>
{{#if error}}
<p>{{error}}</p>
{{/if}}
Get the verification code
The second step of the 2FA process is verifying that the code entered by the user after receipt matches the code we have stored. To do this create another HTML template within your "views" directory named inputCode.handlebars
with the content below. This template will consist of two forms.
The first form contains a hidden input used to pass the user's phone number on to the server when validating the verification code as well as a visible input for the user to enter the 2FA code sent to their device. Submission of this first form will issue a POST
request to the /verify-number
endpoint (shown in form tag as method="post" action="/verify-number"
).
The second form will be conditionally rendered in the event of an error and will be used to re-send the verification code if the user enters an invalid code (either incorrect or expired).
<p> Verification code sent</p>
<p>Please enter the code here:</p>
<form method="post" action="verify-number">
<input type="hidden" name="number" value="{{number}}" />
<input type="text" name="code" />
<input type="submit" value="Check code" />
</form>
{{#if error}}
<p>{{error}}</p>
<form method="post" action="send-verification">
<input type="hidden" name="number" value="{{number}}"/>
<input type="submit" value="re-send code"/>
</form>
{{/if}}
Success message
The final step in the process will be to render a success message to the user on successful verification of the code. To do this create a file in the "views" directory named success.handlebars
with the following content:
<p>Success, your phone number has been verified!</p>
Step 4: Send SMS message
For the next part we'll be leveraging the FreeClimb API to send an SMS message containing a verification code. To do this create a file in your project directory named messenger.js
and add the following content:
const freeClimbSDK = require('@freeclimb/sdk')
const accountId = process.env.ACCOUNT_ID
const apiKey = process.env.API_KEY
const freeClimb = freeClimbSDK(accountId, apiKey) // create FreeClimb Client using your account Id and api key
const fcNumber = process.env.FC_PHONE_NUMBER
exports.sendCode = async (to, code) => {
await freeClimb.api.messages.create(fcNumber, to, `Your verification code is: ${code}`) // send SMS message using FreeClimb client
}
Step 5: 2FA code storage & verification
Code storage
In a new file in your project directory named codeStore.js
, create and export a Map
object for storing pending 2FA codes:
const codes = new Map()
module.exports = codes
Create, store, verify, and delete 2FA codes
Next, create another file in your project directory named verification.js
. This is where we'll create, save, delete, and verify all our 2FA codes. Add the following to your verification.js
file:
const codes = require('./codeStore') // import our code storage map from codeStore.js
const EXPIRY_INTERVAL_MS = 60 * 1000 // create a default amount of time for 2FA code expiry - in this case 60000 ms or 1 min
const createCode = () => {
return Math.floor(100000 + Math.random() * 900000).toString() // create verification code as random 6 digit number represented with a string
}
const addCode = (number, code) => {
codes.set(number, { code: code, expiresAt: Date.now() + EXPIRY_INTERVAL_MS }) // set code with expiration time, keyed to phone number
}
const deleteCode = number => {
codes.delete(number) // remove code
}
const verifyCode = (number, code) => {
if (codes.get(number).code !== code) { // throw err if code not present in codes map
throw new Error('Your code does not match.')
} else if (Date.now() >= codes.get(number).expiresAt) { // throw err if current time is past expiry time of code
throw new Error('Your code has expired.')
}
}
module.exports = {
createCode,
addCode,
deleteCode,
verifyCode
}
Step 6: Serve the webpages
Finally, we will put all the pieces of our application together by adding the logic and rendering the templates we defined earlier within the appropriate routes in index.js
.
Import files
Before defining any endpoints we must import the files we use to both interact with the FreeClimb API and verify our 2FA codes. Do this by uncommenting the following files in the header of your index.js
files:
// uncomment the lines below when you are ready to add in FreeClimb API functions
const msg = require('./messenger') // be sure to place this line after env.config() as "messenger.js" needs access to the loaded environment variables
const verification = require('./verification')
Render first webpage
The /
endpoint will serve as our entryway into the application and will render our first template to allow the user to enter their phone number (inputPhone.handlebars
). Begin by altering the endpoint we defined for GET
requests for the /
route in Step 2. Instead of res.status(200).send('hello world')
, it should now be res.status(200).render('inputPhone')
. See below:
app.get('/', (req, res) => {
res.status(200).render('inputPhone') //no need to include .handlebars
})
Handle verifications
Next, we define an endpoint to handle POST
requests to the /send-verification
endpoint. Add the following to index.js
:
app.post('/send-verification', async (req, res) => {
const number = req.body.number // get the phone number send with the incoming request (number var defined in inputPhone.handlebars template)
const code = verification.createCode() // use verification to create a new 2FA code
verification.addCode(number, code) //add 2FA code to storage
try {
await msg.sendCode(number, code) // attempt to send an SMS message to the number entered by the user
res.status(200).render('inputCode', { number: number }) // on success render the inputCode.handlebars template, pass along the phone number for use in code verification
} catch (err) {
console.error(err) //on failure to send SMS, log error to console
verification.deleteCode(number) // remove 2FA code
res.status(500).render('inputPhone', {
error:
'Something went wrong with your request please make sure you entered a valid phone number.' // re-render the inputPhone.handlebars template with this error message
})
})
}
})
Render success or error webpage
Lastly, we define the endpoint to handle POST
requests to the /verify-number
endpoint. Here we will check to see if a code provided by the user is valid and either render a success message or error notification/re-send button based on the results.
app.post('/verify-number', (req, res) => {
number = req.body.number // pull phone number off request
code = req.body.code // pull 2FA code off request
try {
verification.verifyCode(number, code)
verification.deleteCode(number) // if 2FA code successfully verified remove it to prevent re-use
res.status(200).render('success') // render success.handlebars template
} catch (err) {
console.error(err)
res.status(500).render('inputCode', { error: err.message, number: number }) // render inputCode.handlebars template with error message, pass along phone number for verification code resend
}
})
Step 7: Run your 2FA app
To see your new 2FA app in action, run the following command at the command line:
yarn start
Once you do this, direct your browser to localhost://{your-port-from-.env}
. From there you should be able to enter your phone number and verify it.
If you are trying to run this sample app while using a trial account, make sure that the number you use to test 2FA is a verified number.
Congrats! You can now build your own custom two-factor authentication. 🥳🥳
Updated 3 months ago