Secure Shell (SSH) is a network protocol that enables users to securely connect from one computer to another remotely. The majority of people that use SSH are system administrators, and the most common use for this protocol is to manage a server remotely.
It doesn't matter what point of your technical career you are at; if you deal with servers, you will have needed SSH as a protocol to modify or maintain remote servers on countless occasions. You may have increased security on your server by using an SSH certificate instead of a username and password. However, someone with malicious intent could still gain access to these authentication methods. That’s why it’s worth enabling multi-factor authentication (MFA) for your SSH server.
The IDlayr PhoneCheck API confirms the ownership of a mobile phone number by checking for the presence of an active SIM card with the same number. As well as creating a frictionless user experience, this method is significantly more secure than legacy methods such as SMS OTP. IDlayr APIs call the mobile network directly rather than relying on vulnerable SS7 protocols, providing a real-time SIM card check in under 2 seconds.
Before you begin
To follow along with this tutorial, you'll need:
- A IDlayr Account
- A mobile phone with an active data SIM card
- Docker installed locally
- Node installed locally
Getting started
A repository branch has been created on GitHub containing the foundation code needed to get you started.
In your Terminal, clone the starter-files
branch for this repository with the following command:
git clone -b starter-files [email protected]:tru-ID/tru-id-ssh-auth.git
If you're interested in the finished code, you can find the complete example in the main
branch. To get this code, run:
git clone -b main [email protected]:tru-ID/tru-id-ssh-auth.git
A IDlayr Account is required to make the PhoneCheck
API requests, so before proceeding, make sure you've created one.
Now navigate to the IDlayr console and create a project. Once created, before you close the window or navigate away, be sure you download the tru.json
credentials file and move it to the root directory of your tru-id-ssh-auth
project directory.
Within the credentials file, you'll find your project_id
, project_name
, the scopes available to your project, and your client_id
and client_secret
, which you'll use to create an auth token to make API requests.
Define core variables
Throughout your code, you'll be reusing several bits of information; these are variables such as the PhoneCheck URL and the destination directory where your project is installed, for example. Below the line VERSION=1
in the file ssh-auth
, add the following:
# Base URL informationDATA_RESIDENCY=euBASE_URL="https://$DATA_RESIDENCY.api.idlayr.com"# API Check URLsPHONE_CHECK_URL="$BASE_URL/phone_check/v0.2/checks"GET_PHONE_CHECK_URL="$BASE_URL/phone_check/v0.2/checks/"CREATE_AUTH_TOKEN_URL="$BASE_URL/oauth2/v1/token"APP_ROOT=`dirname $0`CONFIG_FILE="$APP_ROOT/tru-id-ssh-auth.conf"DEST_DIRECTORY="/usr/local/bin/"# Empty Global VariablesBASIC_AUTH_USER=BASIC_AUTH_PASSWORD=ACCESS_TOKEN=
The only two values available for you to change are:
DATA_RESIDENCY
, which could be either of the two supported data residencies;eu
orin
,DEST_DIRECTORY
, which is the destination for the project once installed.
Create the install
The majority of this project will be within one single bash file, with multiple commands available. The first is the install
command, which copies the relevant code to the desired installation directory. You'll also apply a ForceCommand
to the SSHd config file, which you will add later in the tutorial.
Start by locating the function require_curl
within the ssh-auth
file. Below this require_curl
function, add the following code, which is your new install()
function:
function install() {config_file="${DEST_DIRECTORY}tru-id-ssh-auth/tru-id-ssh-auth.conf"set -eecho "Making directory in ${DEST_DIRECTORY}"mkdir "/usr/local/bin/tru-id-ssh-auth"set +eif [[ ! -r `dirname "${DEST_DIRECTORY}"` ]]thenecho "${DEST_DIRECTORY} is not writable. Try again using sudo"return $FAILfiecho "Copying /root/tru-id-ssh-auth/ to ${DEST_DIRECTORY}..."cp -r "/root/tru-id-ssh-auth" "${DEST_DIRECTORY}"echo "Setting up permissions..."chmod 755 $DEST_DIRECTORYif [[ ! -f ${config_file} ]]thenecho "Generating initial config on ${config_file}..."echo "header=IDlayr SSH Auth initialised." > "${config_file}"elseecho "A config file was found on ${config_file}. Edit it manually if you want to change the API key"fichmod 644 ${config_file}echo ""echo "To enable IDlayr authentication on a user the following command: "echo ""echo " ${DEST_DIRECTORY}tru-id-ssh-auth/ssh-auth register-user <username> <phone-number-inc-country-code>"echo " Example: ${DEST_DIRECTORY}tru-id-ssh-auth/ssh-auth register-user test 447000000000"echo ""echo "To uninstall IDlayr SSH type:"echo ""echo " ${DEST_DIRECTORY}tru-id-ssh-auth/ssh-auth uninstall"echo ""echo " Restart the SSH server to apply changes"echo ""}
There are several steps included in this new function. These are:
- Make the directory for the location of the installed application,
/usr/local/bin/tru-id-ssh-auth
. - Check the directory is writeable. If not, then throw an error.
- Copy the project files to the newly created destination directory.
- Check if a config file exists. If not, then create one.
- Output text to Terminal, instructing users how to use and uninstall the application.
As previously mentioned, more functionality is needed to uninstall this script. Below your newly created install()
function, add the following uninstall()
function:
function uninstall() {find_sshd_configif [[ $1 != "quiet" ]]thenecho "Uninstalling IDlayr SSH from $SSHD_CONFIG..."fiif [[ -w $SSHD_CONFIG ]]thensed -ie '/^ForceCommand.*tru-id-ssh-auth.*/d' $SSHD_CONFIGfiif [[ $1 != "quiet" ]]thenecho "IDlayr SSH was uninstalled."echo "Now restart the ssh server to apply changes and then remove ${DEST_DIRECTORY}/tru-id-ssh-auth"fi}
The first line within this new function is to call a function called find_sshd_config()
, so let's add this:
function find_sshd_config() {echo "Trying to find sshd_config file"if [[ -f /etc/sshd_config ]]thenSSHD_CONFIG="/etc/sshd_config"elif [[ -f /etc/ssh/sshd_config ]]thenSSHD_CONFIG="/etc/ssh/sshd_config"elseecho "Cannot find sshd_config in your server. IDlayr SSH Auth will be enabled when you add your specific ForceCommand to the sshd config file."fi}
The new function locates your server's sshd_config
file and stores this file location as a global variable.
Both the install()
and uninstall()
functions are inaccessible until you add them as a parameter. In the list of commands for this project, find the line:
case $1 in
This section of code contains the functionality that understands various arguments in the command when running your bash script. So below the case
line, add the following two new command-line arguments:
install)check_dependenciesinstall;;uninstall)uninstall;;
Registering users
The SIM authentication process requires users to be enabled individually by entering their phone number and linking it with their username to allow them to verify with IDlayr's PhoneCheck API.
To register a new user, create a new register_user()
function by copying the below example in your ssh_auth
file:
function register_user() {config_file="${DEST_DIRECTORY}tru-id-ssh-auth/tru-id-ssh-auth.conf"if [[ $2 && $3 ]]thenecho "user=$2:$3" >> ${config_file}echo "" >> ${config_file}echo "User was registered"elseecho "Cannot register user"fi}
Your new register_user()
function finds the tru-id-ssh-auth.conf
file and adds the following as a new line: user={username}:{phone number}
.
Near the bottom of the file, find the line case $1 in
. Below this line, enable the register_user()
function as a command-line argument by adding the following:
register-user)check_dependenciesregister_user "$@";;
Allow the User in
Once the PhoneCheck is successful, the function run_shell
will be called, which tells the plugin the user is allowed access to their SSH session, in the ssh-auth
file, add the following function:
# Once successful, allows user to proceed with access to serverfunction run_shell() {if [[ "$SSH_ORIGINAL_COMMAND" != "" ]]thenexec /bin/bash -c "${SSH_ORIGINAL_COMMAND}"elif [ $SHELL ]thenexec -l $SHELLfiexit $?}
Creating a PhoneCheck
Retrieve credentials
To make calls to the IDlayr API, you'll need to retrieve your project's credentials, which you can find in the tru.json
file you moved into your project earlier. The get_credentials()
function below retrieves your project's client_id
and client_secret
from this file, then sets them as two global variables: BASIC_AUTH_USER
and BASIC_AUTH_PASSWORD
. Add this new function to your ssh-auth
file:
function get_credentials() {FILE="${DEST_DIRECTORY}tru-id-ssh-auth/tru.json"if [ -f "$FILE" ]; thenBASIC_AUTH_USER=$(jq -r ".credentials[].client_id" ${FILE})BASIC_AUTH_PASSWORD=$(jq -r ".credentials[].client_secret" ${FILE})return $OKfiecho "Unable to retrieve project credentials. Please make sure your project directory has a `tru.json` file."exit $FAIL}
Create access tokens
The next part to creating a PhoneCheck
via API is to add functionality to generate access tokens using your IDlayr project's credentials. This new functionality will make a POST request to https://{DATA_RESIDENCY}.api.idlayr.com/oauth2/v1/token
, with a header containing your BASIC_AUTH_USER
and BASIC_AUTH_PASSWORD
, concatenated with a :
in between, and then the entire string encoded with base64. The access token received will be what you use to make further secure API requests to IDlayr's API.
Copy the create_access_token()
function below into your ssh_auth
file.
# Creates an access token needed to create a PhoneCheck request.function create_access_token() {get_credentialsCREDENTIALS=$(echo -ne "$BASIC_AUTH_USER:$BASIC_AUTH_PASSWORD" | base64 -w 0);# Make request to get access tokenresponse=`curl \--header "Authorization: Basic $CREDENTIALS" \--header "Content-Type: application/x-www-form-urlencoded" \--request POST $CREATE_AUTH_TOKEN_URL\--data-urlencode "grant_type=client_credentials" \--data-urlencode "scope=phone_check coverage" --silent`curl_exit_code=$?# Parses response to get the access tokenACCESS_TOKEN=$(jq -r .access_token <<< "${response}" )if [ $curl_exit_code -ne 0 ]thenecho $curl_exit_codeecho "Error running curl"fi}
Create a PhoneCheck
You've created functions that allow you to retrieve your project's credentials from your tru.json
file; your next function then uses the credentials from this file to generate an access token that you're going to use to make authenticated API calls.
The next step in this tutorial is to initialise the PhoneCheck, which will do the following:
- Retrieve the current user attempting to log in.
- Read the config file to find whether this user has their phone number registered to require the MFA step.
- Make a
POST
request tohttps://{DATA_RESIDENCY}.api.idlayr.com/phone_check/v0.2/checks"
with the user's phone number.
Copy the function below into your ssh_auth
file to add the functionality described into your project:
function create_check() {config_file="${DEST_DIRECTORY}tru-id-ssh-auth/tru-id-ssh-auth.conf"current_user=$USER;phone_number="";while read line; doif [[ $line =~ user=$current_user: ]] ; thenphone_number=$(echo "${line}" | sed s/user=${current_user}://g);fidone <${config_file}if [ "$phone_number" == "" ]; thenecho "Phone Number is empty"return 0fi# Checking Credentials are installedget_credentials# Creating an Access Tokencreate_access_token# Making a Phone Check request"response=`curl \--header "Authorization: Bearer $ACCESS_TOKEN" \--header "Content-Type: application/json" \--request POST $PHONE_CHECK_URL\--data-raw "{\"phone_number\":\"${phone_number}\"}" --silent`curl_exit_code=$?if [ $curl_exit_code -ne 0 ]thenecho "Error running curl"return 1;fi# Handling Phone Check Responsecheck_id=$(jq -r .check_id <<< "${response}" )status=$(jq -r .status <<< "${response}" )check_url=$(jq -r ._links.check_url.href <<< "${response}" )return $CHECK_STATUS;}
Generate a QR code
When the user has created a PhoneCheck
, the next step in the MFA flow is for the user's device to open their mobile network operator's check URL.
The easiest way for the user to open the URL is with a QR code. If you check your Dockerfile
within your project directory, you've already installed the library qr
when you built the Docker container. So the next step is to take the check_url
retrieved from the PhoneCheck
response. This check_url
needs to be converted into a QR code and displayed for the user.
Inside create_check()
, find the line check_url=$(jq -r ._links.check_url.href <<< "${response}" )
, and below it, add the following:
# Generate QR codeqr --ascii "${check_url}" > "qrcode.txt"sleep 1cat ~/qrcode.txt
Set up polling
The next step in this process is for your application to check IDlayr's API endpoint https://{DATA_RESIDENCY}.api.idlayr.com/phone_check/v0.2/checks/{check_id}
to determine whether there was a match between the phone number and the request to the check URL or not. There is a two-minute window when the check is created for the user to open the URL. The application will make requests to the API every five seconds for a status update.
If the status value is COMPLETED
and the response body contains the value of match
as true
, then allow the user through; otherwise, refuse entry to the server.
Copy the new start_polling()
function into your ssh_auth
file:
function start_polling() {# Check every 5 seconds for status on Check.interval_in_seconds=5CHECK_STATUS=$FAILwhile true;do# Check status of phone checkresponse=`curl \--header "Authorization: Bearer $ACCESS_TOKEN" \--header "Content-Type: application/json" \--request GET $GET_PHONE_CHECK_URL/${check_id} --silent`curl_exit_code=$?if [ $curl_exit_code -ne 0 ]thenecho "Error running curl"return $FAIL;fistatus=$(jq -r .status <<< "${response}" )match=$(jq -r .match <<< "${response}" )# If check is complete, outputif [[ "$status" != "PENDING" && "$status" != "ACCEPTED" ]]; thenif [ "$status" == "COMPLETED" ]; thenif [ "$match" == "true" ]; thenCHECK_STATUS=$OK;run_shellelseecho "No match found!"CHECK_STATUS=$FAILreturn $FAIL;fielif [ "$status" == "EXPIRED" ]; thenecho "Check Expired";CHECK_STATUS=$FAILreturn $FAIL;elif [ "$status" == "ERROR" ]; thenecho "Check Error Received";CHECK_STATUS=$FAILreturn $FAIL;elseecho "$status"echo "404, no status was found";CHECK_STATUS=$FAILreturn $FAIL;fibreak;fi# Otherwise continuesleep $interval_in_seconds;done}
You need to call this new function within your create_check()
function. Find the line cat ~/qrcode.txt
, and below this, add the following to trigger the start_polling
functionality:
# Start pollingstart_polling
Add the create_check
as a command-line argument by finding the line: case $1 in
, and adding the following:
create-check)check_dependenciescreate_check "$@";;
Add ForceCommand
In your ssh-auth
file, you've already called the function add_force_command
, but it doesn't yet exist to add the ForceCommand
to your sshd_config
file. So add this function:
function add_force_command() {echo "Trying to add force command to $SSHD_CONFIG"find_sshd_configauth_ssh_command="$1"if [[ -w $SSHD_CONFIG ]]thenecho "Adding 'ForceCommand ${auth_ssh_command} login' to ${SSHD_CONFIG}"uninstall "quiet" # remove previous installationsecho -e "\nForceCommand ${auth_ssh_command} login" >> ${SSHD_CONFIG}echo ""check_sshd_config_fileecho " MAKE SURE YOU DO NOT MOVE/REMOVE ${auth_ssh_command} BEFORE UNINSTALLING IDlayr SSH Plugin"sleep 5fi}
If you're following along with this tutorial using Docker, open the sshd_config
file within your project directory and at the bottom of this file add the following force command:
ForceCommand /usr/local/bin/tru-id-ssh-auth/ssh-auth create-check
Once added you can skip to Complete PhoneCheck. However, if you're installing this on a server, please continue with the instructions below:
Note: If you're following along with this tutorial using the Docker container, you won't need this code snippet; however, when installing on a server other than Docker, the line is needed and needs to be uncommented. Find the line
chmod 644 ${config_file}
inside yourinstall()
function and add the following; you'll also need to uncomment it.
# When following this tutorial, leave the line below commented out.# Restarting your SSH server within Docker will restart the whole Docker container.# add_force_command "${DEST_DIRECTORY}tru-id-ssh-auth/ssh-auth create-check"
Complete PhoneCheck
The final step in the PhoneCheck process is to complete the PhoneCheck. When the mobile device opens the mobile network operator's check URL, they're eventually redirected back to your redirect_url
, which on the final request, will have a response containing a code
. This code needs to be submitted through the API with your credentials to complete the PhoneCheck.
In your starter-files
repository, you will find a template webserver built in node. This is the webserver that will contain the code of your redirect_url
that will carry out this functionality.
Install third party dependencies
In your Terminal, navigate to the webserver
directory and run the following command to install the third party libraries required for this tutorial. These libraries include express
for the webserver functionality, ngrok
to provide a publicly accessible URL, and http-signature
to verify the signature in the API request.
npm install
Create the webhook
Find the endpoint:
app.get("/", async (req, res) => {res.send('hello');});
And replace it with:
app.get("/complete-check", async (req, res) => {if (!req.query) {res.status(400).send("body missing");return;}const { code, check_id } = await req.query;if (!code) {res.status(400).send("code missing");return;}if (!check_id) {res.status(400).send("check_id missing");return;}if (req.query.redirect_url) {const verified = await tru.verifySignature(req.query.redirect_url);if (!verified) {res.status(400).send("signature not verified");return;}}});
In the above example, you're creating a new webhook /complete-check
which is accessible with a GET
request. This request takes three parameters in the URL, check_id
, code
, and redirect_url
. These parameters are used to complete the PhoneCheck. Once checks have been made to make sure they've been included, the code verifys the signature is valid for the redirect url.
Next, within the webserver
directory, create a new file tru.js
which is where you're going to add the IDlayr API functionality. In this file, the first thing is to add the dependencies that will be used, as well as defining the global variables and objects. Add the following:
const moment = require("moment");const fetch = require("node-fetch");const httpSignature = require("http-signature");const jwksClient = require("jwks-rsa");const config = require("../tru.json");const tru_api_base_url = 'https://eu.api.idlayr.com';const keyClient = jwksClient({jwksUri: `${tru_api_base_url}/.well-known/jwks.json`,});// token cache in memoryconst TOKEN = {accessToken: undefined,expiresAt: undefined,};
When making a request to an endpoint in IDlayr's API, you'll need an access token:
async function getAccessToken() {// check if existing valid tokenif (TOKEN.accessToken !== undefined && TOKEN.expiresAt !== undefined) {// we already have an access token let's check if it's not expired// I'm removing 1 minute just in case it's about to expire better refresh it anywayif (moment().add(1, "minute").isBefore(moment(new Date(TOKEN.expiresAt)))) {// token not expiredreturn TOKEN.accessToken;}}const url = `${tru_api_base_url}/oauth2/v1/token`;const toEncode = `${config.credentials[0].client_id}:${config.credentials[0].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",}),});if (!res.ok) {return res.status(400).body("Unable to create access token")}const json = await res.json();// update token cache in memoryTOKEN.accessToken = json.access_token;TOKEN.expiresAt = moment().add(json.expires_in, "seconds").toString();return json.access_token;}
A new function patchPhoneCheck
is needed, which will make a PATCH
request to IDlayr's API: /phone_check/v0.2/checks/${checkId}
, with the code contained in the body. This is a method to verify the owner of the SIM card was the one that requested the PhoneCheck. In your tru.js
file add the following new function:
async function patchPhoneCheck(checkId, code) {const url = `${tru_api_base_url}/phone_check/v0.2/checks/${checkId}`;const body = [{ op: "add", path: "/code", value: code }];const token = await getAccessToken();const requestHeaders = {Authorization: `Bearer ${token}`,"Content-Type": "application/json-patch+json",};const res = await fetch(url, {method: "patch",headers: requestHeaders,body: JSON.stringify(body),});if (!res.ok) {return res;}const json = await res.json();return json;}
As previously explained, you need to verify the signature provided with the redirect_url
, to ensure it hasn't been altered in any way. This can be carried out with the following function, so add this to your file:
async function verifySignature(originalUrl) {try {const url = new URL(originalUrl);const signature = Buffer.from(url.searchParams.get("authorization"),"base64").toString("utf-8");const date = Buffer.from(url.searchParams.get("date"), "base64").toString("utf-8");url.searchParams.delete("authorization");url.searchParams.delete("date");const originalRequest = {url: `${url.pathname}${url.search}`,method: "get",hostname: url.hostname,headers: {date,host: url.host,authorization: signature,},};const parsedOriginalRequest = httpSignature.parseRequest(originalRequest, {clockSkew: 300,});const jwk = await keyClient.getSigningKey(parsedOriginalRequest.keyId);const verified = httpSignature.verifySignature(parsedOriginalRequest,jwk.getPublicKey());return verified;} catch (err) {console.error(err);return false;}}
The two new functions patchPhoneCheck
and verifySignature
need to be accessed in your index.js
file, so at the bottom of the tru.js
file add the following exports for these two:
module.exports = {patchPhoneCheck,verifySignature};
Back in your index.js
file, at the top among the requires, add the line:
const tru = require("./tru");
Find the complete-check
endpoint, and at the bottom of this function, add the following:
try {const check = await tru.patchPhoneCheck(check_id, code);if (check.status === "COMPLETED" && check.match) {res.status(200).send('Verification complete, please close this tab and return to your SSH session.');return;} else {// verification failed = user not authenticatedres.status(401).send("Verification failed, false match");return;}} catch (err) {console.log(err);if (err.status) {res.status(err.status || 500).send(err.message);} else {res.status(500).send("Unexpected Server error");}return;}
The above code calls the patchPhoneCheck
created in your tru.js
file, which makes a PATCH
request to the IDlayr API with the body containing the code
needed to complete the PhoneCheck process.
Now in your Terminal, inside the webserver
directory, run the following command and make note of the ngrok
url output:
npm start
In your ssh-auth
file, find the line: # API Check URLs
and below this add the following, replacing <Your NGROK URL>
, with the ngrok URL you made a note of in the previous step:
BACKEND_SERVER="<Your NGROK URL>"REDIRECT_URL="$BACKEND_SERVER/complete-check"
To have the final redirect be your specified webhook URL, find the line below:
--data-raw "{\"phone_number\":\"${phone_number}\"}" --silent`
And replace it with:
--data-raw "{\"phone_number\":\"${phone_number}\", \"redirect_url\":\"${REDIRECT_URL}\"}" --silent`
Setting up the Docker container
This tutorial makes use of a Docker container for development purposes. To build this Docker container and have it running, you'll need to:
- Build and run your Docker container. This process will also map the internal port
22
(ssh) to an externally accessible port223
. - Open the Docker container with a bash session.
So, in your Terminal, run the following two commands:
docker-compose up --build -ddocker-compose exec ssh bash
Installing the SSH Plugin
You've now added multi-factor authentication to your SSH authentication process. With your Docker container built and running, change to the project directory in the same terminal instance. For this, the default directory is /root/tru-id-ssh-auth/
. Then run the command ./ssh-auth install
to install your copy of your project directory over to /usr/local/bin/
.
Note: This is defined in your
Dockerfile
at the line:ADD . /root/tru-id-ssh-auth
cd /root/tru-id-ssh-auth/./ssh-auth install
The command ./ssh-auth install
will do the following:
- Copy your project directory from
/root/tru-id-ssh-auth/
to/usr/local/bin/tru-id-ssh-auth/
. - Create a
/usr/local/bin/tru-id-ssh-auth/tru-id-ssh-auth.conf
config file.
Registering a user
With the plugin installed, you now need to enable the check for the user(s). This stores the user's name and phone number into your recently created config file. The application will then compare this with the credentials entered when the user attempts to log in. Still in the same Terminal, run the following command, swapping out the placeholders for your valid details:
Note: The Docker user and password are both
test
.
/usr/local/bin/tru-id-ssh-auth/ssh-auth register-user <username> <phone-number-inc-country-code># For example: /usr/local/bin/tru-id-ssh-auth/ssh-auth register-user test 447000000000
Login attempt
You've now set everything up. To check everything is working, open a new Terminal session and run the following command to SSH into your SSH server:
The Docker config example uses the username test
and the password test
.
Wrapping up
That's it! You've now introduced a multi-factor authentication step for your server's SSH authentication process using IDlayr's PhoneCheck
API. The beauty of this is it limits the user's input, by only having to require the user to enter their SSH credentials and then scan the QR code, they don’t need to wait for a code to come through SMS or other means for example. The MFA process is all carried out in the background once the QR code has been scanned on their mobile device.