AWS SNS OTP Provider for OpenIAM

OPENIAM
Published: April 5, 2023

This is part of a series of posts about OpenIAM. OpenIAM is an Identity Management platform that helps organisations manage the digital identities of its workforce and customers.

Check out the OpenIAM category for other posts.

In this post I'm going to show you how to setup and use AWS Simple Notification Service (SNS) as an SMS OTP Provider within OpenIAM 4.2.1.0 or above. OTP codes can be sent out to OpenIAM users as an authentication or two-factor authentication mechanism, they can also be used to verify new phone numbers and contact details.

As I am already using AWS to host OpenIAM, it makes sense to make use of the AWS ecosystem for this as well. Keeping it inside AWS simplifies my billing and it also means I don't have to battle our procurement department to get another service or company approved. AWS currently provide two ways to send out SMS; SNS and PinPoint. The latter is used for customer engagement and two-way communication which is definitely overkill for what we need. SNS should be enough for our use case. Keep in mind that SNS and PinPoint aren't available in all regions. There might also be extra steps that you need to complete to comply with local legislation for your country. Currently, those sending messages to India, Singapore or USA will need to register with a relevant government body before sending messages (Additional charges may apply for registration). You may also be required to provision a dedicated phone number which is quite pricey. A dedicated shortcode number in the UK is $1,500 a month! Within the UK, we don't have to do anything extra and do not need to provision a dedicated number.

Before we get started, you will need an AWS account and OpenIAM 4.2.1.0 or higher. The release of OpenIAM 4.2.1.0 saw some drastic changes to the way OTP Providers are implemented which we are going to make full use of to implement AWS SNS. Prior to 4.2.1.0, OTP Providers were just simple Groovy scripts. If you wanted to provide any configuration such as API keys, you had to either hardcode the key in the groovy script, make use of Spring Framework's @value annotation to inject environment variables into the script or use the PropertyValueCache. This perhaps wasn't the most flexible or the safest way of providing sensitive values. One of the biggest, very welcome changes in 4.2.1.0 allows you to configure attributes for OTP Providers directly within the WebConsole and store sensitive values such as API keys inside Hashicorp Vault. Any attributes attached to an OTP Provider configuration can be accessed from inside the provider itself. Version 4.2.1.0 also saw both the out of the box Twilio and SMSGlobal OTP Providers rewritten as Java classes rather than Groovy scripts, which are both now bundled into the ESB codebase. Though the out of the box providers have been moved, you can still write your own using Groovy as a Custom OTP Provider. If you are using an older version of OpenIAM such as 4.2.0.11 or below, you may have some luck modifying the code below to get it working. Apart from getting configuration from the OTP Provider configuration, the main difference is that the Groovy scripts now need to extend AbstractOTPProvider rather than AbstractSMSOTPProvider. From what I can see, the rest of the code is the same with the need to implement the abstract methods validate(String, LoginEntity), getText(String, LoginEntity, String) and send(String, LoginEntity, String), which are each called in that order. The validate method is called first and gives you the chance to perform any validation you might need such as checking configuration or whether the phone number provided is valid. getText is then called allowing you to programmatically build the text to be sent to the user. Finally send is called and allows you to make API calls required to send the message. It's within this send methods we will perform several calls to the AWS API to send out the OTP token to an end-user.

Setting Up AWS

Simple Notification Service

Though this step is outside the scope of this article, if you don't have an account head on over to console.aws.amazon.com and get yourself setup. The cost per message varies from country to country. Though we do have a population of international users, a large majority are primarily based in the UK, where the current charges are $0.038 per message which is fairly reasonable. When you first get started with SNS, your account will be sandboxed which limits what you can do. While sandboxed, your account is limited to $1 a month spending limit which for me works out at 26 SMS messages. Also you can only send messages to a maximum of 10 verified phone numbers. This means you have to register each number you want to send messages to inside SNS. Though this might seem strange, this is designed to protect phone users from spam and sending messages accidentally while testing. You can remove these limitations by raising a request to exit sandboxing, but I'd encourage you to remain sandboxed while you test out SNS so your account doesn't become disputed for spam or even receiving a bill shock due to making a mistake! If you have multiple accounts, or use different regions, you will need to submit a support request for each account or region you are using. It's worth taking a look at the full AWS SNS documentation for more information about the service and the ins and outs of how to use it.

As I'm based in the UK, I'm only going to cover what you need to do for my geographic location which will be eu-west-2. Let's start by making a small number of configuration changes to your AWS SNS service.

Login to the AWS Console.

AWS Console Navigation

Navigate to the SNS Console.

AWS SNS Navigation

On the left-hand side, expand the menu and navigate to Mobile -> Text messaging (SMS)

AWS SNS Text Message Console

This page contains several sections such as delivery reports (not turned on by default), statistics, verified numbers, settings etc.

AWS SNS Account Information - Sandboxed
AWS SNS Account Information - Not Sandboxed

The first panel Account Information will tell you whether your account is still sandboxed or not.

AWS SNS Sandbox destination phone numbers

If your account is still sandboxed you will see the Sandbox destination phone numbers panel. This is where you add and verify phone numbers that you are allowed to send to while your account is still sandboxed.

AWS SNS Delivery Status Logs
AWS SNS Delivery Status Logs

The Delivery status logs panel shows you information related to delivery logs, however, this feature is not enabled by default. It can be extremely helpful to know why a message has not been delivered so having this enabled is recommended.

AWS SNS Statistics

The Delivery statistics panel gives you a timeline bar chart showing you how many messages have been sent successfully and how many have failed.

AWS SNS Text messaging preferences

The Text messaging preferences panel shows you the current settings related to SMS, this includes the default Sender ID, the current monthly spend limit in dollars and a few other settings.

The monthly spend limit for all accounts defaults to $1 a month and is slightly misleading. Though you can change this value, it cannot be set above the Service Quota limit that has been applied to the account. Effectively, you have two spending limits, one that you can control and one that AWS impose as an absolute maximum. You can view your account limits in the Service Quotas Console. The Service Quota limit can only be changed by AWS Support and defines the maximum value you can set the monthly spend limit to. To get this increased, you need to submit a support request to get your service quota increased. Increasing the service quota does not automatically change the monthly spending limit shown in the settings panel and must be updated manually by you when AWS Support have updated your service limit. As I mentioned earlier the $1 spending limit works out at 26 messages for my account which is fine for testing.

The default Sender ID is blank, if your region supports using Sender IDs you can set this to your brand name. If you do not set one, the word "NOTICE" is used. It can be a maximum of 11 alphanumeric characters including hyphens. It cannot contain spaces and must start and end with an alphanumeric character. In supported regions, SMS will appear to end users as being delivered by this Sender ID. In some regions you need to register for a Sender ID before you can use this. Throughout my examples, we will be managing IT Accounts for a fictional university "University of Life". With that in mind I've set the Sender ID to "LIFE".

If you are still sandboxed, go up to the Sandbox destination phone number panel and click Add phone number.

AWS SNS Add a phone number

Enter a valid phone number and click Add phone number. (The number shown is disposable number, so I cannot be contacted on it.)

Alt message

AWS SNS will send a verification code to the number entered. Enter it on this screen and click Verify phone number.

To test sending an SMS, scroll back to the top of the screen and click the Public text message button.

Publish SMS Message

Select Transcational as the message type. If you are sandboxed you will only be able to select a verified number as the destination, otherwise you can enter a number freely. Once you've written your test message, scroll to the bottom and click Publish message.

Received SMS Message

AWS Credentials

To be able to use SNS, we need to create programmatic access credentials with an IAM user. Switch over to IAM and click on Users on the left menu. Click on the blue Add User button.

AWS IAM Create User

Add an appropriate username, I've named my user OpenIAM-SNS-User. Click Next.

AWS IAM User Set Permissions

On the Set permissions screen, select Attach policy directly and add the AmazonSNSFullAccess policy. Click Next.

AWS IAM User Review and Create

Review the details and ensure the right policy is attached, then click Create. You will be taken back to the list of users within your account, click on the newly created user.

AWS IAM User Overview

Click on the Security Credentials tab.

AWS IAM User Security Credentials - Access Keys

Scroll down until you find the Access keys panel. Click on Create access key.

AWS IAM User Credential  Creation

I'm going to select Application running outside AWS, make sure you read any warnings shown before continuing.

AWS IAM User Credentials Tags

If you want to add tags to the credentials, add them here, otherwise click Create access key.

AWS IAM User Retrieve Access Keys

Make sure you securely store a copy of the credentials from this screen as you will not be shown the Secret access key again.

Setting up OpenIAM

The OpenIAM configuration consists of uploading a new groovy script and creating a new OTP Provider.

Groovy Script

One of the big problems I faced with writing this within groovy is that we do not have access to the AWS SDK. We are limited to the libraries that OpenIAM already have and specifically the part of OpenIAM that runs the groovy script. I believe OTP Providers are called from ESB, so we only have access to the same libraries that have been bundled with it. As there isn't a way to import new libraries within groovy, our task is a little more complicated than it should be. Using the AWS SDK or even the CLI takes care of a lot procedural processes. Each request made to the AWS API must be signed using AWS Signature Version 4. This uses your IAM credentials to sign the request you are making and authenticates you to the API. The SDKs and CLI deal with this request signing for you, but without access to these tools, we are going to have to figure out how to sign requests ourselves. Luckily for you, I've already taken the time to deal with that. Unfortunately this has resulted in a fairly large groovy script.

OpenIAM Administration Menu → Groovy Manager

Log into the WebConsole and go to Administration Menu -> Groovy Manager

OpenIAM Groovy Script Manager

Save the following code as a new script at /AM/OTP/AWSSNSOTPModule.groovy

/AM/OTP/AWSSNSOTPModule.groovy
import java.text.SimpleDateFormat
import java.util.LinkedHashMap
import java.util.Map
import java.util.TimeZone
import java.util.concurrent.TimeUnit
import javax.crypto.Mac
import javax.crypto.spec.SecretKeySpec
import org.apache.commons.codec.binary.Hex
import org.apache.commons.codec.digest.DigestUtils
import org.apache.commons.lang3.StringUtils
import org.apache.commons.logging.Log
import org.apache.commons.logging.LogFactory
import org.apache.http.HttpEntity
import org.apache.http.HttpHeaders
import org.apache.http.client.methods.CloseableHttpResponse
import org.apache.http.client.methods.HttpGet
import org.apache.http.impl.client.CloseableHttpClient
import org.apache.http.impl.client.HttpClients
import org.apache.http.util.EntityUtils
import org.openiam.base.ws.ResponseCode
import org.openiam.esb.core.auth.module.AbstractOTPModule
import org.openiam.esb.core.otp.service.OTPProviderService
import org.openiam.exception.BasicDataServiceException
import org.openiam.http.client.OpenIAMHttpClient
import org.openiam.http.model.HttpClientResponseWrapper
import org.openiam.idm.searchbeans.OTPProviderSearchBean
import org.openiam.idm.srvc.auth.domain.LoginEntity
import org.openiam.idm.srvc.otp.dto.OTPProvider
import org.openiam.idm.srvc.otp.dto.OTPProviderName
import org.redisson.api.RMap
import org.redisson.api.RedissonClient
import org.springframework.beans.factory.annotation.Autowired

/**
 * AWSSNSOTPModule - AWS SNS SMS OTP Provider
 * 
 * Can be used to send SMS OTP using AWS SNS
 * 
 * In the OTP Provider set the following:
 * 
 * AWS_REGION          = region to use SNS in
 * AWS_ACCESS_KEY_ID
 * AWS_SECRET_ACCESS_KEY
 * GROOVY_PATH         = Path of this script
 * TEXT_MESSAGE_FORMAT = String with text you wish to send, %s will be replaced by the OTP code e.g. "You code is: %s" (default if not provided)
 * 
 * The script will be called in the following order:
 * - validate
 * - getText
 * - send
 *   - getSandboxStatus
 *     - makeAPICall
 *   - getOptOutStatus
 *     - makeAPICall
 *   - sendMessage
 *     - makeAPICall
 * 
 * @OpenIAM: >= 4.2.1.2 
 * @Author: Neil Herbert <info@ea3.co.uk>
 */
class AWSSNSOTPModule extends AbstractOTPModule {
    
    private static final String ALGORITHM            = "HmacSHA256"
    private static final String OTP_PROVIDER_NAME    = "Text OTP by AWS SNS" // Set this to the name of your configured OTP Provider
    private static final String DEFAULT_TEXT_MESSAGE = "Your code is %s"
    private static final String DEFAULT_COUNTRY_CODE = "+44"
    private static final String AWS_DEFAULT_REGION   = "eu-west-2"
    private static final String AWS_URL              = "amazonaws.com"
    private static final String AWS_SERVICE_NAME     = "sns"
    private static final Integer THROTTLE_IN_SEC     = 300 // Number in seconds
    private static final Integer THROTTLE_MAX_REQ    = 5 // Maximum number of OTP requests in the THROTTLE_IN_SEC timeframe
    
    private static final Log log = LogFactory.getLog("AWSSNSOTPModule")
    @Autowired
    protected OTPProviderService otpProviderService
    protected OTPProvider otpProvider
    @Autowired
    private RedissonClient redissonClient
    
    /**
     * AWSSNSOTPModule constructor
     *
     * Find OTP Provider when initialised
     */
    public AWSSNSOTPModule() {
        log.info("Initialised")
        log.info("Finding OTP Provider")
        OTPProviderSearchBean sb = new OTPProviderSearchBean()
        sb.setName(OTP_PROVIDER_NAME)
        otpProvider = otpProviderService?.find(sb, 0, 1)?.getContent()[0]
    }

    /**
     * validate()
     * 
     * Validate that the provider has been found and the required attributes are available
     * 
     * @param String phone - Phone number to send message to
     * @param LoginEntity login - Account to send code for
     */
    protected void validate(String phone, LoginEntity login) throws BasicDataServiceException {
        log.info("Validate called")
        log.debug("Phone: ${phone}")
        log.debug("Validating OTP Provider")
        if (otpProvider == null) {
            throw new BasicDataServiceException(ResponseCode.AUTH_PROVIDER_NOT_FOUND)
        } else {
            // Check that OTP Provider is configured
            String awsRegion = otpProvider.getValue("AWS_REGION")
            log.debug("AWS_REGION: ${awsRegion}")
            String awsAccessKeyId = otpProvider.getValue("AWS_ACCESS_KEY_ID")
            String awsSecretAccessKey = otpProvider.getValue("AWS_SECRET_ACCESS_KEY")
            if (awsRegion == null || awsAccessKeyId == null || awsSecretAccessKey == null) {
                throw new BasicDataServiceException(ResponseCode.RESULT_INVALID_CONFIGURATION)
            }
        }
        
        log.info("Validating phone number")
        log.debug(getPhoneNumber(phone))
        def phoneNumberRegex=/\+\d{1,14}/
        if(!phone.matches(phoneNumberRegex)) {
            log.warn("Phone number not valid")
            throw new BasicDataServiceException(ResponseCode.WRONG_TYPE_IS_FOR_SMS)
        }
        
        // Check if phone number is to be throttled
        RMap<String,Integer> map = redissonClient.getMap("otp-throttle-${getPhoneNumber(phone)}")
        // Get count for phone number
        Integer count = map.get("count") ?: 0 // Set to 0 if null
        count++ // Increse count
        map.put("count", count) // Put count
        map.expire(THROTTLE_IN_SEC, TimeUnit.SECONDS) // Set expiry timeout
        log.info("Number of OTP Requests in the last ${THROTTLE_IN_SEC/60} minutes: ${count}")
        if (count > THROTTLE_MAX_REQ) {
            log.error("There have bene too many requests to send an OTP token to ${getPhoneNumber(phone)}")
            throw new BasicDataServiceException(ResponseCode.SMS_TOKEN_GENERATE_ERROR)
        }
    }

    /**
     * getText()
     * 
     * Get TEXT_MESSAGE_FORMAT from OTP Provider or use a default
     *
     * @param String phone - Phone number to send message to
     * @param LoginEntity login - Account to send code for
     * @param String token - generated code
     * @return String
     */
    protected String getText(String phone, LoginEntity login, String token) {
        log.info("getText called")
        log.debug("phone: ${phone}, token: ${token}")
        String textMessageFormat = otpProvider.getValue(OTPProviderName.TEXT_MESSAGE_FORMAT)
        textMessageFormat = StringUtils.isBlank(textMessageFormat) ? DEFAULT_TEXT_MESSAGE : textMessageFormat
        return String.format(textMessageFormat, token)
    }

    /**
     * send()
     * 
     * Send the OTP code via AWS SNS
     * @param String phone - Phone number to send message to
     * @param LoginEntity login - Account to send code for
     * @param String text - message to send
     */
    protected void send(String phone, LoginEntity login, String text) {
        log.info("Send called")
        log.debug("Formatting Input Phone Number")
        String phoneNumber = getPhoneNumber(phone)
        log.debug("Phone Number: ${phoneNumber}")
        log.debug("Message: $text")
        
        // Check if account is in sandbox
        boolean sandboxStatus = getSandboxStatus("true")
        if (sandboxStatus) {
            log.info("Your AWS Account is Sandboxed. You will only be able to send messages to verified phone numbers.")
        }
        
        // Check if phone number has been opted out
        boolean optOutStatus = getOptOutStatus(phoneNumber)
        if (optOutStatus) {
            log.info("This phone number has opted out of receiving messages from your AWS Account. Message cannot be sent.")
            throw new BasicDataServiceException(ResponseCode.WRONG_TYPE_IS_FOR_SMS)
        }
        
        // Send Message
        sendMessage(phoneNumber, text)
    }
    
    /**
     * getSandboxStatus()
     * 
     * Checks to see if the AWS Account assosicated with the Caller Identity if sandboxed
     * https://docs.aws.amazon.com/sns/latest/api/API_GetSMSSandboxAccountStatus.html
     * 
     * @param String - Not used - required to get groovy to compile/save
     * @return Boolean
     */
    protected boolean getSandboxStatus(String check) {
        log.info("Checking if AWS Account is Sandboxed")
        // Query string to append to HTTP Request - must be in alphabetical order A-Za-z
        Map<String, String> queryParams = new LinkedHashMap<>()
        queryParams.put("Action", "GetSMSSandboxAccountStatus")
        try {
            String response = makeAPICall(queryParams)
            log.debug(response)
            if (response.contains("\"IsInSandbox\":true")) {
                log.debug("Account is Sandboxed")
                return true
            }
        } catch (Exception e) {
            log.error(e)
        }
        return false
    }
    
    /**
     * getOptOutStatus()
     * 
     * Checks to see if the phone number has opted out of receiving messages from your AWS SNS account
     * https://docs.aws.amazon.com/sns/latest/api/API_CheckIfPhoneNumberIsOptedOut.html
     * 
     * @param String phoneNumber
     * @return Boolean
     */
    protected boolean getOptOutStatus(String phoneNumber) {
        log.info("Checking if phone number has opted out of SMS from this AWS Account")
        // Query string to append to HTTP Request - must be in alphabetical order A-Za-z
        Map<String, String> queryParams = new LinkedHashMap<>()
        queryParams.put("Action", "CheckIfPhoneNumberIsOptedOut")
        queryParams.put("phoneNumber", phoneNumber)
        try {
            String response = makeAPICall(queryParams)
            log.debug(response)
            if (response.contains("\"isOptedOut\":true")) {
                log.debug("Number has opted out")
                return true
            }
        } catch (Exception e) {
            log.error(e)
        }
        return false
    }
    
    /**
     * sendMessage()
     * 
     * Send SMS
     * https://docs.aws.amazon.com/sns/latest/api/API_Publish.html
     * 
     * @param String phoneNumber - Phone number to send out to, must be in E.164 format
     * @param String text - Message to send to user
     */
    private void sendMessage(String phoneNumber, String text) {
        log.info("Sending Message")
        
        // Query string to append to HTTP Request - must be in alphabetical order A-Za-z
        Map<String, String> queryParams = new LinkedHashMap<>()
        queryParams.put("Action", "Publish")
        queryParams.put("Message", text)
        queryParams.put("PhoneNumber", phoneNumber)
        
        // Commented out while testing throttling
        String response = makeAPICall(queryParams)
        log.info("API Response: ${response}")
    }
    
    /**
     * makeAPICall()
     * 
     * Performs an API Call to the AWS API
     * 
     * @param Map<String, String> queryParams - A list of query string parameters to send to the API
     * @return String - JSON Response from API Call as a String
     */
    private String makeAPICall(Map<String, String> queryParams) {
        log.info("makeAPICall Called")
        // Get AWS Credentials from OTP Provider
        log.debug("Getting aws credentials from OTP Provider")
        String awsRegion = otpProvider.getValue("AWS_REGION") ?: AWS_DEFAULT_REGION
        String awsAccessKeyId = otpProvider.getValue("AWS_ACCESS_KEY_ID")
        String awsSecretAccessKey = otpProvider.getValue("AWS_SECRET_ACCESS_KEY")
        
        // Get Date & Time
        log.debug("Getting Date & Time")
        SimpleDateFormat sdf = new SimpleDateFormat("yyyyMMdd'T'HHmmss'Z'", Locale.ENGLISH)
        sdf.setTimeZone(TimeZone.getTimeZone("UTC"))
        String dateTime = sdf.format(new Date()).trim()
        log.debug("Date & Time: ${dateTime}")
        String date = dateTime.substring(0,8)
        log.debug("Date: ${date}")
        
        // Build host name
        String host = String.format("%s.%s.%s", AWS_SERVICE_NAME, awsRegion, AWS_URL)
        log.debug("Host: ${host}")
    
        // Create empty StringBuilders for AWS Signature V4
        StringBuilder queryString      = new StringBuilder("")
        StringBuilder canonicalRequest = new StringBuilder("")
        StringBuilder signedHeaders    = new StringBuilder("")
        StringBuilder stringToSign     = new StringBuilder("")
        
        // Headers to add to HTTP request
        Map<String, String> headers = new LinkedHashMap<>()
        headers.put("Accept", "application/json")
        headers.put(HttpHeaders.CONTENT_TYPE, "application/json; charset=utf-8")
        headers.put("Host", host)
        headers.put("X-Amz-Date", dateTime)

        // Query string to append to HTTP Request - must be in alphabetical order A-Za-z
        queryParams.put("X-Amz-Algorithm", "AWS4-HMAC-SHA256")
        queryParams.put("X-Amz-Credential", String.format("%s/%s/%s/%s/aws4_request", awsAccessKeyId, date, awsRegion, AWS_SERVICE_NAME))
        queryParams.put("X-Amz-Date", dateTime)
        
        // Add signed headers to queryParams
        Integer count = 1
         if (headers != null && !headers.isEmpty()){
            log.debug("Adding signed headers to queryParams")
            for (Map.Entry<String, String> entry : headers.entrySet()) {
                log.debug("Entry: ${count} ${entry.getKey()}")
                signedHeaders.append(entry.getKey().toLowerCase())
                if (count < headers.size()) {
                    signedHeaders.append(";")
                }
                count++
            }
        }
        queryParams.put("X-Amz-SignedHeaders", signedHeaders.toString())
        
        // Sort queryParams Alphabetically by Code Point (int value)
        log.debug("Sorting Query Params")
        Map<String,String> sortedQueryParams = new LinkedHashMap<>()
        Map<String,String> UpperCaseEntries = new LinkedHashMap<>()
        Map<String,String> LowerCaseEntries = new LinkedHashMap<>()
        
        for (Map.Entry<String, String> entry : queryParams.entrySet()) {
            char key = entry.getKey().charAt(0)
            if (key.isLowerCase()) {
                LowerCaseEntries.put(entry.getKey(), entry.getValue())
            } else {
                UpperCaseEntries.put(entry.getKey(), entry.getValue())
            }
        }
        log.debug("Upper Case: ${UpperCaseEntries}")
        log.debug("Lower Case: ${LowerCaseEntries}")
        
        if (UpperCaseEntries != null && !UpperCaseEntries.isEmpty()) {
            log.debug("Sorting Upper Case Entries")
            Map<String, String> sortedUpperCaseEntries = new TreeMap<String, String>(UpperCaseEntries)
            log.debug(sortedUpperCaseEntries)
            sortedQueryParams.putAll(sortedUpperCaseEntries)
        }
        
        if (LowerCaseEntries != null && !LowerCaseEntries.isEmpty()) {
            log.debug("Sorting Lower Case Entries")
            Map<String, String> sortedLowerCaseEntries = new TreeMap<String, String>(LowerCaseEntries)
            log.debug(sortedLowerCaseEntries)
            sortedQueryParams.putAll(sortedLowerCaseEntries)
        }
        log.debug("QueryParams: ${queryParams}")
        log.debug("Sorted QueryParams: ${sortedQueryParams}")
        
        log.debug("Building Query String")
        log.debug("QueryParams Size: ${queryParams.size()}")
        count = 1
        for (Map.Entry<String, String> entry : sortedQueryParams.entrySet()) {
            log.debug("Entry: ${count}")
            queryString.append(encode(entry.getKey())).append("=").append(encode(entry.getValue()))
            if (count < queryParams.size())
                queryString.append("&")
            count++
        }
        log.debug(queryString)
        
        log.debug("Building Initial URL for HTTP Request")
        String url = String.format("https://%s/?%s", host, queryString)
        log.debug("URL: ${url}")
        
        // AWS Signature V4 Signing Request - https://docs.aws.amazon.com/general/latest/gr/sigv4_signing.html
        log.info("Generating AWS Signature V4")
        // Step 1 - Generate a canonical request
        log.debug("Generating canonicalRequest")
        canonicalRequest.append("GET").append("\n") // HTTP Method
        canonicalRequest.append("/").append("\n") // HTTP Request Path
        canonicalRequest.append(queryString).append("\n") // HTTP Query String
        // Add HTTP Headers
        if (headers != null && !headers.isEmpty()){
            log.debug("Adding headers to canonicalRequest")
            count = 1
            for (Map.Entry<String, String> entry : headers.entrySet()) {
                log.debug("Entry: ${count} ${entry.getKey()}")
                canonicalRequest.append(entry.getKey().toLowerCase()).append(":").append(entry.getValue().trim())
                if (count < headers.size()) {
                    canonicalRequest.append("\n")   
                } else {
                    canonicalRequest.append("\n")
                }
                count++
            }
            canonicalRequest.append("\n").append(signedHeaders).append("\n")
        }
        canonicalRequest.append(DigestUtils.sha256Hex(("").toString()))
        log.debug("canonicalRequest: ${canonicalRequest}")
        // Step 2 - Generate a String to Sign
        log.debug("Generating StringToSign")
        stringToSign.append("AWS4-HMAC-SHA256").append("\n")
        stringToSign.append(dateTime).append("\n")
        stringToSign.append(date).append("/").append(awsRegion).append("/").append(AWS_SERVICE_NAME).append("/").append("aws4_request").append("\n")
        stringToSign.append(DigestUtils.sha256Hex(canonicalRequest.toString()))
        log.debug("StringToSign: ${stringToSign}")

        // Step 3.1 - Derive signing key
        log.debug("Deriving Signing Key")
        byte[] signatureKey = getSignatureKey(awsSecretAccessKey, date, awsRegion, AWS_SERVICE_NAME)
        log.debug("SignatureKey: ${Hex.encodeHexString(signatureKey)}")
        // Step 3.2 - Calculate Signature
        byte[] signature = HmacSHA256(stringToSign.toString(), signatureKey)
        log.debug("Signature: ${Hex.encodeHexString(signature)}")
            
        log.info("Creating HTTP Request")
        log.debug("Adding Signature to Query")
        url += "&X-Amz-Signature=${Hex.encodeHexString(signature)}"
        
        HttpGet request = new HttpGet(url)
        log.debug("Adding Headers")
        for (Map.Entry<String, String> entry : headers.entrySet()) {
            // Do not add Host header again
            if (entry?.getKey()?.toLowerCase() != "host") {
                log.debug("Adding ${entry.getKey()}")
                request.addHeader(entry.getKey(), entry.getValue())
            }
        }

        CloseableHttpClient httpClient = HttpClients.createDefault()
        CloseableHttpResponse response = httpClient.execute(request)
        
        String responseStatus = response.getStatusLine().getStatusCode()
        log.info("Response Status Code: ${responseStatus}")
        log.debug("Headers: ${response.getAllHeaders()}")

        HttpEntity entity = response.getEntity()
        String result = ""
        if (entity != null) {
            result = EntityUtils.toString(entity)
            log.debug("HTTP Response: ${result}")
        } else {
            log.warn("HTTP Request did not return anything")
        }
        
        if (responseStatus != "200") {
            log.error("HTTP Request returned an error: ${response}")
            throw new BasicDataServiceException(ResponseCode.SEND_PUSH_NOTIFICATION_FAILED)
        }
        return result
    }

    /**
     * getPhoneNumber()
     *
     * Take the entered phone number and convert it to required format
     * 
     * @param String
     * @return String
     */
    private static String getPhoneNumber(String number) {
        return number.replaceAll("[^0-9\\+]", "")                    // Remove non-numeric characters apart from a +
                     .replaceAll("(.)(\\++)(.)", "${1}${3}")         // Remove + from middle of string
                     .replaceAll("^\\+0", "${DEFAULT_COUNTRY_CODE}") // If number starts with +0, replace with default Country Code
                     .replaceAll("^\\+00", "+")                      // Convert 00 numbers to +
    }
    
    /**
     * encode()
     * 
     * URI Encode a string to AWS' standards 
     * 
     * @param String
     * @return String
     */
    private static String encode(String value) {
        char[] input = value.toCharArray()
        StringBuilder output = new StringBuilder("")
        for (char ch : input) {
            // Do not encode A-Za-z0-9-_.~
            if (ch.isLetterOrDigit() || ch == "-" || ch == "_" || ch == "." || ch == "~") {
                output.append(ch)
            } else {
                // Percent encode all other values
                output.append("%${Integer.toHexString((int) ch).toUpperCase()}")
            }
        }
        return output
    }
    
    /**
     * HmacSHA256()
     * 
     * Generate HMAC SHA256 hash - from https://docs.aws.amazon.com/general/latest/gr/signature-v4-examples.html#signature-v4-examples-java
     * 
     * @param String data - input to hash
     * @param byte[] key - key to hash data with
     * @return byte[]
     */
    private static byte[] HmacSHA256(String data, byte[] key) throws Exception {
        Mac mac = Mac.getInstance(ALGORITHM)
        mac.init(new SecretKeySpec(key, mac.getAlgorithm()))
        return mac.doFinal(data.getBytes("UTF-8"))
    }
    
    /**
     * getSignatureKey()
     * 
     * Create signing signature - from https://docs.aws.amazon.com/general/latest/gr/signature-v4-examples.html#signature-v4-examples-java
     * 
     * @param String key - AWS Secret Access Key
     * @param String dateStamp - Date in YYYMMDD format
     * @param String regionName - AWS Region e.g. eu-west-2
     * @param String serviceName - Name of the AWS Service being called in lowercase e.g. sns
     */
    static byte[] getSignatureKey(String key, String dateStamp, String regionName, String serviceName) throws Exception {
        byte[] kSecret = ("AWS4" + key).getBytes("UTF-8")
        byte[] kDate = HmacSHA256(dateStamp, kSecret)
        byte[] kRegion = HmacSHA256(regionName, kDate)
        byte[] kService = HmacSHA256(serviceName, kRegion)
        byte[] kSigning = HmacSHA256("aws4_request", kService)
        return kSigning
    }
}

Let's take a moment to talk through what the code is doing.

At the top of the script just after the AWSSNSOTPModule class is defined, are some constant variables used to set some default configuration.

    private static final String ALGORITHM            = "HmacSHA256"
    private static final String OTP_PROVIDER_NAME    = "Text OTP by AWS SNS" // Set this to the name of your configured OTP Provider
    private static final String DEFAULT_TEXT_MESSAGE = "Your code is %s"
    private static final String DEFAULT_COUNTRY_CODE = "+44"
    private static final String AWS_DEFAULT_REGION   = "eu-west-2"
    private static final String AWS_URL              = "amazonaws.com"
    private static final String AWS_SERVICE_NAME     = "sns"
    private static final Integer THROTTLE_IN_SEC     = 300 // Number in seconds
    private static final Integer THROTTLE_MAX_REQ    = 5 // Maximum number of OTP requests in the THROTTLE_IN_SEC timeframe

These variables define some default values that change how the script works. The most important variable here is the OTP_PROVIDER_NAME String. You will need to make sure this is the same as the name of the OTP Provider that we will configure a little bit later.

The DEFAULT_COUNTRY_CODE is set to my geographical location. Phone numbers must be provided to AWS SNS in the international E.164 format which includes the country dialing code. As no country dial their own full dialing code, we have a default to add to a number if one isn't provided. You may want to review the getPhoneNumber method to make sure the logic used to convert local numbers to the E.164 format is correct for your region as I've written it for the UK.

THROTTLE_IN_SEC AND THROTTLE_MAX_REQ are used to control how many times an OTP token can be requested for a phone number within a set period of time. The defaults allow for 5 requests within 300 seconds or 5 minutes. As SNS charge per SMS, we need to provide some controls to prevent users from unnecessarily requesting tokens and getting a bill shock. SNS charge for every attempt to send a message, whether it's been delivered or not.

The other values should not need to be changed and mostly relate to the request signing.

After this section of code is the script's constructor. This is a method that is run when the script/class is instantiated. The constructor is responsible for fetching the OTP Provider configuration from the database. It searches for an OTP Provider that matches the name set in OTP_PROVIDER_NAME. It's really important that this is set correctly otherwise the script will not work as it won't get the configuration we will set in the UI.

After the constructor is the validate() method, this is used to validate the OTP request. We use this method to check that the AWS configuration has been provided, a valid phone number has been provided and whether the number needs to be throttled. The throttling makes use of a unique redis key with a time-to-live defined. An incremental number is stored against the phone number in redis. Once the phone number has reached the number of requests within THROTTLE_MAX_REQ, further requests will increase the count and reset the expiry. If further requests are not made, the entry within Redis will automatically be purged after the expiry period.

After the validate method is the getText() method. This is used by the script to construct the message to be sent out. This takes the message format defined in the OTP configuration, or the default if not provided and replaces %s with the OTP code.

After the getText method is the send() method. This is responsible for sending the OTP code. This calls a number of other methods to actually send out the message to be delivered and includes checks to see if your AWS SNS account is still sandboxed and whether the number you are sending to has opted out of your service.

The rest of the code is used to make the api calls, so I won't go into details about those.

OTP Provider

Now we have our AWS SNS OTP Provider script, we need to create an OTP Provider.

OpenIAM Navigation Access Control → OTP Providers

Go to Access ControlOTP Providers

OpenIAM OTP Providers

On the left click New OTP Provider.

OpenIAM New OTP Providers

Configure the new OTP Provider:

FieldValue
NameText OTP by AWS SNS
TypeCustom OTP Provider
Failover OTP ProviderLeave blank

Add the following attributes:

Attribute NameStore in SecretValue
GROOVY_PATHNAM/OTP/AWSSNSOTPModule.groovy
AWS_REGIONNeu-west-2
AWS_ACCESS_KEY_IDYAccess Key ID
AWS_SECRET_ACCESS_KEYYSecret Access Key
TEXT_MESSAGE_FORMATNYour verification code is: %s

Ensure that the Name field for the OTP Provider matches the OTP_PROVIDER_NAME constant from our groovy script.

Click Save.

OpenIAM OTP Providers

Navigation back to the list of OTP Providers will show our new provider.

To use this new provider, you need to add the new OTP Provider and enable TOTP support on the Authentication Provider Policy you want to use it on. Instruction on that can be found within the docs.

OpenIAM Authentication Provider

Need help with OpenIAM? Make sure you join the official OpenIAM Community where you can ask for help from other community members.