The tru.ID SubscriberCheck API confirms the ownership of a mobile phone number by confirming the presence of an active SIM card with the same number. A mobile data session is created with a request to a Check URL unique to this SIM card.
tru.ID then resolves a match between the phone number entered by the user and the phone number that the mobile network operator (MNO) identifies as the owner of the mobile data session.
Any website or mobile application that requires some form of authentication using an email or username and a password will have a password reset flow. Usually, this flow involves the user providing identifying information (email, username, phone number, etc.) and then receiving a code via email or SMS or being asked to answer a secret question.
This is how phishing attackers often gain fraudulent access to online accounts by intercepting the code or secret answer. With tru.ID, this exploitation is no longer possible.
In this tutorial, you will start with a basic web application that doesn't yet provide any form of password reset flow.
As you progress through the tutorial, you will adapt your codebase first to check whether the phone number associated with a user’s account is a phone number from a mobile network operator supported by tru.ID. If the phone number is supported, then the tru.ID SubscriberCheck flow will begin – firstly, to check that the phone number and the data connection match, and secondly, to check the SIM card hasn't recently swapped.
By the end of this tutorial, you will have successfully implemented a password reset flow with tru.ID's SubscriberCheck as the primary method of multi-factor authentication, and Vonage's Verify API as the secondary. If the SubscriberCheck is the triggered flow, the user will have a seamless verification process with no action required and no attack vector.
Before you begin
To follow along with this tutorial, you'll need the following:
- A tru.ID Account
- Node.js installed locally on your computer
- A mobile phone with an active data SIM card
Getting started
A tru.ID Account is needed to make the SubscriberCheck API requests, so make sure you've created one.
You're also going to need some Project credentials from tru.ID to make API calls. So sign up for a tru.ID account, which comes with some free credit. We've built a CLI for you to manage your tru.ID account, projects, and credentials within your Terminal. To install the tru.ID CLI run the following command:
npm install -g @tru_id/cli
Run tru login <YOUR_IDENTITY_PROVIDER>
(this is one of google
, github
, or microsoft
) using the Identity Provider you used when signing up. This command will open a new browser window and ask you to confirm your login. A successful login will show something similar to the below:
Success. Tokens were written to /Users/user/.config/@tru_id/cli/config.json. You can now close the browser
Note: The config.json
file contains some information you won't need to modify. This config file includes your Workspace Data Residency (EU, IN, US), your Workspace ID, and token information such as your scope.
Create a new tru.ID project within the root directory with the following command:
tru projects:create password-reset-web --project-dir .
Make a note of these tru.ID credentials because you'll need them later in the tutorial.
Note: The users set up in the database for test purposes are configured based on tru.ID's SubscriberCheck Sandbox conditions.
In your Terminal, clone the starter-files
branch with the following command:
git clone -b starter-files git@github.com:tru-ID/web-based-password-reset-tutorial.git
If you're only interested in the finished code in main
, then run:
git clone -b main git@github.com:tru-ID/web-based-password-reset-tutorial.git
The starter-files
branch already contains some third-party libraries needed to get you started. So in your Terminal, within the project directory, run the following command to install these third-party libraries:
npm install
npx sequelize-cli db:migrate # to create the database, and run the migrations.
npx sequelize-cli db:seed:all # to seed the test users into the database.
First, you'll need to expose your project to the Internet for a callback. So to initialize the ngrok tunnel by running the following command:
npx ngrok http 3000
Next, to run the web server, run the following command in your Terminal, in the project directory:
npm start
Once you've started the server, you'll have also created a ngrok tunnel, which shows output similar to what's shown in the example below:
[nodemon] 2.0.16
[nodemon] to restart at any time, enter `rs`
[nodemon] watching path(s): *.*
[nodemon] watching extensions: js,mjs,json
[nodemon] starting `node index.js`
Example app listening at http://localhost:4000
Starting Ngrok now
Ngrok connected, URL: https://<subdomain>.ngrok.io
Below is a screenshot of the landing page you'll be shown when you open localhost:4000
or your ngrok URL in your browser:
The Login
screen reached by clicking the Login
button will show you a page similar to what's shown in the image below:
Implementing tru.ID SubscriberCheck
Install tru.ID Web SDK
To carry out a SubscriberCheck, we'll be using the tru.ID Web SDK. Install this SDK into your application with the following command:
npm install @tru_id/tru-sdk-web
tru.ID Access Token
You'll want to avoid hardcoding credentials and sensitive information in your codebase; for this, we'll be using Environment Variables. Install dotenv
package into your project:
npm install dotenv
Inside src/server.js
find the line: const port = 4000;
and below this add the following:
require('dotenv').config()
Now, create a .env
file in the root directory of your project and add the following:
TRU_ID_CLIENT_ID=
TRU_ID_CLIENT_SECRET=
EXTERNAL_URL=
Note: Populate the values of the above with your own tru.ID client_id and client_secret, while the
EXTERNAL_URL
is your ngrok URL.
To make API requests to tru.ID you will need to generate an access token with your client_id
and client_secret
. First, create a new directory called api
within your src
directory. And within this new directory, create the tru.js
file. Populate this file with the following:
const moment = require("moment");
const fetch = require("node-fetch");
const httpSignature = require("http-signature");
const jwksClient = require("jwks-rsa");
const tru_api_base_url = 'https://eu.api.tru.id';
const keyClient = jwksClient({
jwksUri: `${tru_api_base_url}/.well-known/jwks.json`,
});
// token cache in memory
const TOKEN = {
accessToken: undefined,
expiresAt: undefined,
};
async function getAccessToken() {
// check if existing valid token
if (TOKEN.accessToken !== undefined && TOKEN.expiresAt !== undefined) {
// we already have an access token. Let's check if it's not expired
// by removing 1 minute because the access token is about to expire, so it's better to refresh anyway
if (
moment()
.add(1, "minute")
.isBefore(moment(new Date(TOKEN.expiresAt)))
) {
// token not expired
return TOKEN.accessToken;
}
}
const url = `${tru_api_base_url}/oauth2/v1/token`;
const toEncode = `${process.env.TRU_ID_CLIENT_ID}:${process.env.TRU_ID_CLIENT_SECRET}`;
const auth = Buffer.from(toEncode).toString('base64');
const requestHeaders = {
Authorization: `Basic ${auth}`,
"Content-Type": "application/x-www-form-urlencoded",
};
const res = await fetch(url, {
method: "post",
headers: requestHeaders,
body: new URLSearchParams({
grant_type: "client_credentials",
scope: "phone_check coverage subscriber_check",
}),
});
if (!res.ok) {
return res.status(400).body("Unable to create access token")
}
const json = await res.json();
// update token cache in memory
TOKEN.accessToken = json.access_token;
TOKEN.expiresAt = moment().add(json.expires_in, "seconds").toString();
return json.access_token;
}
Reachability
Creating the Coverage Check
The first step is to determine whether the data connection from the device is a cellular data connection and is supported by tru.ID – a Coverage check. Within your tru.js
file, add the following new method:
async function checkCoverage(req, res) {
const accessToken = await getAccessToken()
const ip = req.headers['x-forwarded-for'] || req.connection.remoteAddress;
const deviceCoverageResponse = await fetch(
`${tru_api_base_url}/coverage/v0.1/device_ips/${ip}`,
{
headers: {
Authorization: `Bearer ${accessToken}`,
},
},
)
if (deviceCoverageResponse.status === 200) {
// Loop through products to double check we cover SubscriberCheck for this MNO
const data = await deviceCoverageResponse.json()
for (var counter = 0, length = data.products.length; counter < length; counter++) {
if (data.products[counter] !== null) {
if (data.products[counter].product_id === 'SUK') {
return true
}
}
}
} else if (deviceCoverageResponse.status === 400) {
// tru.ID has no coverage for this mobile network operator default to SMS
console.log('tru.ID has no coverage for this mobile network operator. Sending an SMS OTP')
return false
} else if (deviceCoverageResponse.status === 412) {
// Default to SMS
console.log('IP address is not a mobile IP Address, here, you could suggest the user disabled WiFi. Sending an SMS OTP')
return false
} else {
// Unexpected result from device coverage check. Default to SMS
console.log('Unexpected result from coverage check. Sending an SMS OTP')
return false
}
}
module.exports = {
getAccessToken,
checkCoverage
}
Create a new routes file within /src/routes
called tru.js
. This file will contain the API routes your server needs to process the tru.ID SubscriberCheck flow.
Within this new file, add the following functionality. This functionality takes the IP address of the device accessing the web page to determine whether it is on a mobile network IP address or tru.ID and the mobile network operator has coverage.
const truApi = require('../api/tru')
async function checkCoverage(req, res) {
try {
const coverageCheckRes = await truApi.checkCoverage(req, res)
return res.status(200).json({coverage: coverageCheckRes})
} catch (error) {
console.log(error)
res.sendStatus(500)
}
}
module.exports = {
checkCoverage,
}
Now you need to expose this new endpoint through the server. So, open /src/routes.js
, and at the top, add the import for your tru.js
routes file:
const tru = require('./routes/tru')
Within your routes()
function, add the following definition for this /api/coverage
endpoint, which will be called from the user's browser:
router.get('/api/coverage', tru.checkCoverage)
Calling the Coverage Check
To call this coverage check, you need to update the password-reset
endpoint's template. Open src/views/password-reset.hbs
, and at the bottom of the file, add the following:
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@tru_id/tru-sdk-web/dist/tru-id-sdk.umd.js"></script>
<script>
// Step 1 - Do coverage check
async function checkCoverage(ev) {
try {
const deviceCoverageResult = await axios.get('/api/coverage', {
validateStatus: (status) => status >= 200 && status <= 412,
})
// If there's no coverage, prompt the user to turn off WiFi if it's enabled and recheck.
if (deviceCoverageResult.status === 200 && deviceCoverageResult.data.coverage === true) {
// tru.ID has coverage.
await createSubscriberCheck(ev)
} else if (deviceCoverageResult.status === 400 || deviceCoverageResult.status === 412) {
// No coverage continue with POST request
console.log('no coverage')
return true
} else {
// Unexpected result, continue with POST request
console.log('unexpected result')
return true
}
} catch (ex) {
// Unexpected result, continue with POST request
console.log('error')
console.log(ex)
return true
}
}
// Step 2 - If covered, do SubscriberCheck(based on the email address....)
async function createSubscriberCheck(ev) {
}
document
.getElementById('password_reset')
.addEventListener('submit', async (event) => {
const coverage = await checkCoverage()
if (coverage !== true) {
event.preventDefault()
}
})
</script>
The above code imports axios
(A promise-based HTTP client for the browser) and tru.ID's SDK
. Following the import, the code creates a function called checkCoverage
.
Within this function, a GET
request is made to your /api/coverage
endpoint to check whether the device is supported based on its IP address. At the bottom of this code, you'll see an event listener. This listener can only call the checkCoverage
function when the user has submitted the button.
SubscriberCheck
Creating the SubscriberCheck
The SubscriberCheck
flow is as follows:
- 1 - Your Backend makes a
POST
request to tru.ID to create a check. - 2 - tru.ID returns in the response a
check_url
. - 3 - Your web application in the user's browser opens that
check_url
in the background. - 4 - This
check_url
is direct to that user's mobile network operator (MNO). The MNO verifies the request. - 5 - Your backend makes a
PATCH
request to tru.ID with thecode
returned at theredirect_url
defined when creating the check.
So, open your /src/api/tru.js
file and add the following code for this functionality from your Backend's actions:
async function createSubscriberCheck(phone) {
// Create SubscriberCheck resource
const token = await getAccessToken()
const subscriberCheckCreateResult = await fetch(`${tru_api_base_url}/subscriber_check/v0.2/checks`, {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
phone_number: phone,
redirect_url: `${process.env.EXTERNAL_URL}/api/sck-password-reset-code`,
}),
});
if (subscriberCheckCreateResult.status === 201) {
const data = await subscriberCheckCreateResult.json()
return data
}
return false
}
async function patchSubscriberCheck(check_id, code) {
const url = `${tru_api_base_url}/subscriber_check/v0.2/checks/${check_id}`
const body = [{ op: 'add', path: '/code', value: code }]
const token = await getAccessToken()
const requestHeaders = {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json-patch+json',
}
const subscriberCheckPatchResult = await fetch(url, {
method: 'PATCH',
headers: requestHeaders,
body: JSON.stringify(body),
});
if (subscriberCheckPatchResult.status === 200) {
const data = await subscriberCheckPatchResult.json()
return data
}
return {}
}
async function getSubscriberCheckStatus(checkId) {
const url = `${tru_api_base_url}/subscriber_check/v0.2/checks/${checkId}`
const token = await getAccessToken()
const requestHeaders = {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
}
const subscriberCheckResult = await fetch(url, {
method: 'GET',
headers: requestHeaders,
});
if (subscriberCheckResult.status === 200) {
const data = await subscriberCheckResult.json()
return data
}
return {}
}
Now update the exports:
module.exports = {
getAccessToken,
checkCoverage,
createSubscriberCheck,
patchSubscriberCheck,
getSubscriberCheckStatus
}
You may have noticed that you've added three functions. All of these will be used through the next steps of this tutorial.
Open /src/routes/tru.js
; at the top, add the following two imports:
const db = require('../models')
const { getHashedPassword } = require('../utils/auth')
As previously explained, this file will handle all routes relevant to tru.ID and the SubscriberCheck. So within this file, as well as the checkCoverage
function, you'll also have:
- createSubscriberCheck - This endpoint takes the phone number and
redirect_url
, making aPOST
request to tru.ID, requesting acheck_url
to be returned to the device. - SubscriberCheckCallback - The endpoint for the previously defined
redirect_url
, where the web application will end up following thecheck_url
. This request will contain therequest_id
andcode
, which will be sent as aPATCH
request to tru.ID to complete the flow. - getScPasswordResetCode - Checks the user has an active SubscriberCheck in progress, then renders the template form for the user to enter their email address and their new password.
- postScPasswordResetCode - Checks the user has an active SubscriberCheck in progress, then saves the user's new password.
Within the tru.js
file, add the following functions:
async function createSubscriberCheck(req, res) {
const { email } = req.body
if (!email) {
return res
.status(400)
.json({ error_message: 'email parameter is required' })
}
const user = await db.User.findOne({ where: { email: email } })
if (!user) {
return res.redirect('password-reset')
}
try {
const subscriberCheckRes = await truApi.createSubscriberCheck(
user.phone
)
if (subscriberCheckRes === false) {
return res.sendStatus(400)
}
// Select data to send to client
return res.status(200).json({
check_id: subscriberCheckRes.check_id,
check_url: subscriberCheckRes._links.check_url.href
})
} catch (error) {
console.log(error)
return res.sendStatus(500)
}
}
async function subscriberCheckCallback(req, res) {
const subscriberCheckResult = await truApi.patchSubscriberCheck(req.query.check_id, req.query.code)
if (subscriberCheckResult === {}) {
return res.redirect('/password-reset-code')
} else if (subscriberCheckResult.match === false || subscriberCheckResult.no_sim_change === false) {
// Not a match or sim changed, return to reset password with a message.
var message = 'Unable to verify, please contact support.'
var messageClass = 'alert-danger'
return res.redirect(`/login?message=${message}&messageClass=${messageClass}`)
} else {
// Success! Move the user to the reset password form.
return res.redirect(`/sc-password-reset-code?check_id=${req.query.check_id}`)
}
}
async function getScPasswordResetCode(req, res) {
const { check_id } = req.query
if (!check_id) {
return res.redirect('/login')
}
const subscriberCheckStatus = await truApi.getSubscriberCheckStatus(check_id)
if (subscriberCheckStatus === {} || subscriberCheckStatus.match === false || subscriberCheckStatus.no_sim_change === false) {
return res.redirect('/login')
}
res.render('sc-password-reset-code', { check_id: check_id })
}
async function postScPasswordResetCode(req, res) {
const { check_id, email, password, password2 } = req.body
if (!check_id) {
return res.redirect('/login')
}
const subscriberCheckStatus = await truApi.getSubscriberCheckStatus(check_id)
if (subscriberCheckStatus === {} || subscriberCheckStatus.match === false || subscriberCheckStatus.no_sim_change === false) {
return res.redirect('/login')
}
const user = await db.User.findOne({ where: { email: email } })
if (!user) {
return res.render('login', {
message: 'Invalid email',
messageClass: 'alert-danger'
})
}
if (password !== password2) {
return res.render('login', {
message: 'Passwords don\'t match',
messageClass: 'alert-danger'
})
}
const hashedPassword = await getHashedPassword(password)
await user.update({ password: hashedPassword })
await user.save()
var message = 'Password has been reset, please log in.'
res.redirect(`/login?message=${message}&messageClass=alert-success`)
}
Now update the exports at the bottom of the file:
module.exports = {
createSubscriberCheck,
checkCoverage,
subscriberCheckCallback,
getScPasswordResetCode,
postScPasswordResetCode
}
Now create the routes for these endpoints to be accessed. Open /src/routes.js
and locate the line router.get('/api/coverage', tru.checkCoverage)
.
Below this line, add the following four new routes:
router.post('/api/subscriber-check', tru.createSubscriberCheck)
router.use('/api/sck-password-reset-code', tru.subscriberCheckCallback)
router.get('/sc-password-reset-code', tru.getScPasswordResetCode)
router.post('/sc-password-reset-code', tru.postScPasswordResetCode)
You may have noticed a template called sc-password-reset-code.hbs
in the new code above. You'll need this, so in /src/views
create a new file called sc-password-reset-code.hbs
and add the following:
<div class="row justify-content-md-center" style="margin-top: 100px">
<div class="col-md-6">
{{#if message}}
<div class="alert {{messageClass}}" role="alert">
{{message}}
</div>
{{/if}}
<form method="POST" action="/sc-password-reset-code">
<input name="check_id" type="hidden" value="{{check_id}}">
<div class="form-group">
<label for="emailInput">Email address</label>
<input name="email" type="email" class="form-control" id="emailInput" placeholder="Enter email">
</div>
<div class="form-group">
<label for="passwordInput">New Password</label>
<input name="password" type="password" class="form-control" id="passwordInput" placeholder="New Password">
</div>
<div class="form-group">
<label for="passwordInput2">Re-enter your new Password</label>
<input name="password2" type="password" class="form-control" id="passwordInput2" placeholder="Re-enter New Password">
</div>
<button type="submit" class="btn btn-primary">Reset my Password</button>
</form>
</div>
</div>
Now, back within /src/views/password-reset.hbs
, you'll need the functionality to call these new routes and complete the SubscriberCheck
section of the flow. Find the Step 2
Line for the function async function createSubscriberCheck(ev) {
. Within this function, add the following:
console.log('creating subscriber check')
try {
const emailInput = document.getElementById('emailInput')
let emailValue = emailInput.value
// strip spaces out of the phone number and replace them within an input
emailValue = emailValue.replace(/\s+/g, '')
emailInput.value = emailValue
emailInput.blur()
// Create SubscriberCheck resource
const subscriberCheckCreateResult = await axios.post('/api/subscriber-check', {
email: emailValue
})
if (subscriberCheckCreateResult.status === 200) {
// Step 3 - Open Check URL. If successful, end up on `password-reset-code`. If it fails, call the fallback endpoint. (SMS)
console.log('opening check url')
console.log(subscriberCheckCreateResult)
await tru.ID.openCheckUrl(subscriberCheckCreateResult.data.check_url, {
checkMethod: 'image',
debug: true,
// we don't care here as we are already doing
// the device coverage check automatically on page load
// through the node server
checkDeviceCoverage: false,
})
} else {
return true
}
} catch (error) {
console.log(error)
if (error.response.status === 400) {
console.log('Your Mobile Network is not supported or you may be on WiFi, if so disconnect from WiFi.')
} else {
console.log('An error occurred while creating a SubscriberCheck.')
}
return true
}
The above piece of code first creates the SubscriberCheck; then, if you've successfully created a check, it will open the check_url
returned from the API request and complete the flow. Otherwise, the user will be redirected to the Vonage Verify flow.
Vonage Verification
If the device's IP address is not a mobile network or the network provided is not supported by tru.ID, a fallback is required. We'll use Vonage's Verification flow for this tutorial to send the verification code via SMS. However, you can define the workflow in Vonage's documentation.
To start implementing Vonage's Verify API into the flow, in your Terminal, in the project's root directory, run the following command:
npm install @vonage/server-sdk
Open your .env
file and update it with the following extra variables. These variables are of your Vonage API credentials:
VONAGE_API_KEY=
VONAGE_API_SECRET=
BRAND_NAME=
WORKFLOW_ID=6 # SMS only.
Next, create a new file within the api
directory to handle your Vonage API calls. Create api/vonage.js
and populate this file with the following:
const { Vonage } = require('@vonage/server-sdk')
async function createVerification(phoneNumber) {
const vonage = new Vonage({
apiKey: process.env.VONAGE_API_KEY,
apiSecret: process.env.VONAGE_API_SECRET
})
const resp = await vonage.verify.start({
number: phoneNumber,
brand: process.env.BRAND_NAME,
workflow_id: process.env.WORKFLOW_ID
})
if (resp.status === '0') {
// Success
return resp.request_id
}
return null
}
async function verify(request_id, code) {
const vonage = new Vonage({
apiKey: process.env.VONAGE_API_KEY,
apiSecret: process.env.VONAGE_API_SECRET
})
console.log(request_id, code)
const verify = await vonage.verify.check(request_id, code)
if (verify.status === "0") {
// Success
return verify.request_id
}
return null
}
module.exports = {
createVerification,
verify
}
This new file you've just created has two functions. The first is to initialise the Verify flow; the second is to handle the response from the user once they receive the code and enter it into the application.
For the Verify flow to work, you'll need to associate a user with a Verify request_id
. So first, open /src/models/user.js
and, within User.init
add the following new field:
requestId: DataTypes.STRING
Now in your Terminal, within the project's root directory, run the following command:
npx sequelize-cli migration:create --name modify_users_add_new_fields
Next, find the new file in /src/migrations
. It will be named <timestamp>-modify_users_add_new_field.js
.
Within the async up()
of this file, replace the contents with:
return Promise.all([
queryInterface.addColumn(
'Users', // table name
'requestId', // new field name
{
type: Sequelize.STRING,
allowNull: true,
},
),
]);
And within the async down()
replace the contents with:
return Promise.all([
queryInterface.removeColumn('Users', 'requestId'),
]);
In your Terminal, run the following command to run the migration:
npx sequelize-cli db:migrate
Locate the /src/routes/auth.js
file and add the following import at the top of the file:
const vonage = require('../api/vonage')
Next, within this file, find the function postPasswordReset
and replace the last four lines of code:
res.render('login', {
message: 'Reset Password Link sent via Email',
messageClass: 'alert-danger'
})
With the following:
const vonageRes = await vonage.createVerification(user.phone)
if (vonageRes !== null) {
await user.update({ requestId: vonageRes })
await user.save()
return res.redirect('password-reset-code')
}
return res.redirect('login')
You'll now need to handle the input from the user, which will contain their email
, the verification code
, and two copies of their password
. Find postPasswordResetCode()
and populate this function with the following:
const { email, password, password2, code } = req.body
// Check if the user with the same email is also registered
const user = await db.User.findOne({ where: { email: email } })
if (!user) {
res.redirect('/login?message=Invalid email&messageClass=alert-danger')
}
if (password !== password2) {
res.render('register', {
message: 'Passwords do not match.',
messageClass: 'alert-danger'
})
}
const vonageRes = await vonage.verify(user.requestId, code)
if (vonageRes !== null) {
const hashedPassword = getHashedPassword(password)
await user.update({ requestId: null, password: hashedPassword })
await user.save()
let message = 'Password successfully reset. Please log in with your new credentials.'
let messageClass = 'alert-success'
return res.redirect(`/login?message=${message}&messageClass=${messageClass}`)
}
let message = 'No password-reset in progress. Please restart.'
let messageClass = 'alert-danger'
return res.redirect(`/login?message=${message}&messageClass=${messageClass}`)
Wrapping up
That's it! You've now introduced a secure password reset flow within your web application using tru.ID's SubscriberCheck API, and a Vonage Verify backup in case the SubscriberCheck is not possible.
The SubscriberCheck verifies that the user is the owner of that phone number, SIM card, and active data connection, and that the SIM card has not recently been swapped.
Top comments (0)