PSD2 Compliant Authorization: Verifying Sensitive Actions with Python, Flask and Authy

February 08, 2019
Written by

Screen Shot 2019-02-07 at 3.48.29 PM.png

Update January 2022

For new development, we encourage you to use the Verify API instead of the Authy API. The Verify API is an evolution of the Authy API with continued support for SMS, voice, and email one-time passcodes, an improved developer experience and new features including:

This blog post uses the Authy API. Any new features and development will be on the Verify API. Check out the FAQ for more information and Verify API Reference to get started.

Adding two-factor authentication (2FA) to your login process increases the security of your user's data. We can extend that to validate sensitive actions like sending money from your account, changing your shipping address, or confirming a medical appointment. Even though the user should be already logged in with a username and password, we want to make sure that they authorize every payment. This blog post will show you how to secure payment actions using Python, Flask, a bit of Javascript, and the Authy API.

PSD2 & SCA

The European Payment Services Directive (PSD2) regulation requires Strong Customer Authentication (SCA) for all transactions over €30 by September 2019. This post will show you how to implement a compliant solution for your application. For more detail on PSD2, SCA, and dynamic linking, check out this post.

The solution in this post is useful regardless of regulatory requirements. For example, Gemini uses push authorizations to validate cryptocurrency withdrawals.

gemini push authentication

What you'll need

To code along with this post, you’ll need:

Setting Up

Start by downloading or cloning the starter application from Github. 

git clone git@github.com:robinske/payfriend-starter.git && cd payfriend-starter

If you haven't already, now is the time to sign up for Twilio and create an Authy Application. Navigate to the Twilio Console and grab your Authy App API Key under Settings.

authy api key

Copy .env.example to .env. Once we have an Authy API key, we can store it in our .env file, which helps us set the environment variables for our app. Update your .env file:

# Secret key
SECRET_KEY=replace-me-in-production

# Authy API Key
# (create an app here https://www.twilio.com/console/authy)
AUTHY_API_KEY=FLc***************************

Installing dependencies

Next, install the necessary dependencies.

On Mac/Linux:

python3 -m venv venv
. venv/bin/activate

Or on Windows cmd:

py -3 -m venv venv
venv\Scripts\activate.bat

Install Requirements:

pip install -r requirements.txt

Now we're ready to run and test our starter application.

Run the application

On Mac OS or Linux operating systems run:

export FLASK_APP=payfriend
export FLASK_ENV=development
flask run

Or on Windows cmd:

set FLASK_APP=payfriend
set FLASK_ENV=development
flask run

Navigate to http://127.0.0.1:5000/auth/register and register yourself as a new user with your real phone number. The application already has phone verification with the Twilio Verify API, which allows us to use the user's trusted phone number for subsequent authorizations.

Phone verification is an important part of PSD2 compliance; we need to trust the device before we can start using it for payment authorizations.

After you've registered you'll end up on a page like this:

payment app screenshot

At this point, you can send a payment of any amount to one of your friends. So if I send $20 to my friend Neville, I'll see that reflected in My Payments:

payment dashboard

What if we wanted additional safeguards to make sure I approved sending that money to Neville?

Registering a User with Authy

When a new user signs up for our website we store them in the database and register the user with Authy. This code is already in the starter project and you can learn more about that process in the Authy API documentation.

Once we register the user with Authy we get the user's Authy ID from the response. This is very important — it's how we will verify the identity of our user with Authy and send subsequent authorizations from our application.

How to Add PSD2 compliant 2FA for Payment Transactions

For this transaction, we will validate that the user has their mobile phone by either:

  • Sending a push notification to their mobile Authy app or
  • Sending a one-time token in a text message sent with the Authy API.

When a user attempts to "Send a Payment" on our website, we will ask them for an additional authorization. Let's take a look at push authorization first.

  • We attempt to send a push approval request to the user
  • If the user has push authentication enabled, we will get a success message back
  • The user hits Approve in their Authy app
  • Authy makes a POST request to our app with an approved status
  • We move forward with sending the payment

Send the Push Request

When our user sends a payment we immediately attempt to verify their identity with a push authorization. We can fall back gracefully if they don't have a smartphone with the Authy app, but we don't know until we try.

Authy lets us pass extra details with our push request including a message, a logo, and any other details we want to send. We could easily send any number of details by adding additional key-value pairs to our details dict. For our scenario this could look like:

details = {
    'Sending to': 'Hermione',
    'Transaction amount': '1,000,000',
    'Currency': 'Galleons'
}

Details will show up in the app. Hidden details can also be included for tracking information about the request (like origin IP address) that you may not need or want to display in the app.

authy push authorization on locked screen and inside app

Let's write the code for sending a push authorization. Head over to payfriend/utils.py and add the following function.

def send_push_auth(authy_id_str, send_to, amount):
    """
    Sends a push authorization with payment details to the user's Authy app

    :returns (push_id, errors): tuple of push_id (if successful)
                                and errors dict (if unsuccessful)
    """
    details = {
        "Sending to": send_to,
        "Transaction amount": str('${:,.2f}'.format(amount))
    }

    hidden_details = {
        "user_ip_address": request.environ.get('REMOTE_ADDR', request.remote_addr),
        "requester_user_id": str(g.user.id)
    }

    logos = [dict(res = 'default', url = 'https://emojipedia-us.s3.dualstack.us-west-1.amazonaws.com/thumbs/120/apple/155/money-bag_1f4b0.png')]

    api = get_authy_client()
    resp = api.one_touch.send_request(
        user_id=int(authy_id_str),
        message="Please authorize payment to {}".format(send_to),
        seconds_to_expire=1200,
        details=details,
        hidden_details={},
        logos=logos
    )

    if resp.ok():
        push_id = resp.content['approval_request']['uuid']
        return (push_id, {})
    else:
        flash(resp.errors()['message'])
        return (None, resp.errors())

This function constructs our push authorization request. We add necessary details about the request like the transaction amount and the payee. We also define the logo that will show up in the request. If the request is successful, resp.ok() will return True and we can grab the authorization uuid from the response. Otherwise we'll return the relevant errors.

Next, head over to payfriend/payment.py and add the code to call our new function. In def send() we only want to add the Payment to the database once we have sent the push authorization. To do that, we'll add a conditional statement after we try to send the push authorization. Replace lines 76-79 (starting with payment =) in the starter project with the highlighted code below.


def send():
    form = PaymentForm(request.form)
    if form.validate_on_submit():
        send_to = form.send_to.data
        amount = form.amount.data
        authy_id = g.user.authy_id

        # create a unique ID we can use to track payment status
        payment_id = str(uuid.uuid4())

        (push_id, errors) = utils.send_push_auth(authy_id, send_to, amount)
        if push_id:
            payment = Payment(payment_id, authy_id, send_to, amount, push_id)
            db.session.add(payment)
            db.session.commit()
            return jsonify({
                "success": True,
                "payment_id": payment_id
            })
        else:
            flash("Error sending authorization. {}".format(errors))
            return jsonify({"success": False})

    return render_template("payments/send.html", form=form)

Once we send the request we update the payment status based on the response. Let's update our payments view to show the status. Open payfriend/templates/payments/list.html and add a column for the status:


<table style="width:100%">
  <tr>
    <th>Your Email</th>
    <th>Payment ID</th>
    <th>Sent to</th>
    <th>Amount</th>
    <th>Status</th>
  </tr>
{% for payment in payments %}
<tr>
  <td>{{ payment.email }}</td>
  <td>{{ payment.id }}</td>
  <td>{{ payment.send_to }}</td>
  <td>{{ "${:,.2f}".format(payment.amount) }}</td>
  <td>{{ payment.status }}</td>
</tr>
{% endfor %}
</table>

Make sure you have the Authy app installed on your phone and that you're registered for Authy with the same phone number that you used to register for Payfriend. Restart the Flask application and send a payment. We're not updating the status yet but this time you'll get an authorization request in the Authy app and see a 'pending' status attached to that new payment ID.

payments

Configure the push authorization callback

In order for our app to know what the user did after we sent the authorization request, we need to register a callback endpoint with Authy.

In payment.py add a new route for the callback. Here we look up the payment using the uuid sent with the Authy POST request.

def update_payment_status(payment, status):
    # once a payment status has been set, don't allow that to change
    # this requires a new transaction in order to be PSD2 compliant
    if payment.status != 'pending':
        flash("Error: payment request was already {}. Please start a new transaction.".format(
            payment.status))
        return redirect(url_for('payments.list_payments'))
 
    payment.status = status
    db.session.commit()

@bp.route('/callback', methods=["POST"])
@verify_authy_request
def callback():
    """
    Used by Twilio to send a notification when the user
    approves or denies a push authorization in the Authy app
    """
    push_id = request.json.get('uuid')
    status = request.json.get('status')
    payment = Payment.query.filter_by(push_id=push_id).first()
 
    update_payment_status(payment, status)
    return ('', 200)

We need one more endpoint that our client side code can query to check the payment status and update our view accordingly. Add this to payment.py:

@bp.route('/status', methods=["GET", "POST"])
@login_required
def status():
    """
    Used by AJAX requests to check the OneTouch verification status of a payment
    """
    payment_id = request.args.get('payment_id')
    payment = Payment.query.get(payment_id)
    return payment.status

Let's take a look at that client-side code now.

Handle Two-Factor Asynchronously

We want to handle our authorization asynchronously so the user doesn't even know it's happening.

We've already taken a look at what's happening on the server side, so let's step in front of the cameras now and see how our JavaScript is interacting with those server endpoints.

First, we hijack the payment form submit and pass the data to our controller using Ajax. If we expect a push authorization response, we then begin polling /payment/status every 3 seconds until we see the request was either approved or denied. Our callback will update /payment/status so we will know when an authorization has been approved or denied.

In auth.js update the sendPayment function to check for the payment authorization status before redirecting the user.

var sendPayment = function(form) {
    $.post("/payments/send", form, function(data) {
      if (data.success) {
        $(".auth-ot").fadeIn();
        checkPaymentStatus(data.payment_id);
      }
    });
  };

var checkPaymentStatus = function(payment_id) {
    $.get("/payments/status?payment_id=" + payment_id, function(data) {
      if (data == "approved") {
        redirectWithMessage('/payments/', 'Your payment has been approved!')
      } else if (data == "denied") {
        redirectWithMessage('/payments/send', 'Your payment request has been denied.');
      } else {
        setTimeout(checkPaymentStatus(payment_id), 3000);
      }
    });
  };

You'll need a publicly accessible route that Authy can access in order to handle the callback and for that we will use ngrok. Note that in this tutorial only the HTTP address from ngrok will work, so you should start it using this command:

ngrok http -bind-tls=false 5000

Copy the Forwarding url:

ngrok screenshot

Head back to Authy Console and update your application's Push Authentication callback URL with /payments/callback appended.

screen shot 2018-12-07 at 3 44 42 pm

Leave ngrok running in the background and try sending a new payment. Now when you approve or deny the request your application will update the payment status and you can see it reflected in your list of payments.

approved and denied payments

Now let's see how to handle the case where a user doesn't have the Authy app installed.

Fallback to SMS

There are reasons that you could include SMS as a verification option: like being able to reach users without smartphones and onboard users seamlessly (no app install required).

In utils.py add the code to send and validate an authorization token via SMS. One important feature we're taking advantage of here is the action and action_message parameters. The action will tie the SMS authorization to the specific transaction, a requirement for PSD2. The action message will add important details to the SMS message body about the payee and amount of the transaction, other requirements for PSD2 and a helpful message to the user regardless of regulation.

action_message psd2
def send_sms_auth(payment):
    """
    Sends an SMS one time password (OTP) to the user's phone_number
 
    :returns (sms_id, errors): tuple of sms_id (if successful)
                               and errors dict (if unsuccessful)
    """
    api = get_authy_client()
    action = "{}|{}".format(payment.send_to, payment.amount)
    session['payment_id'] = payment.id
    session['action'] = action
    options = {
        'force': True,
        'action': action,
        'action_message': 'Verify Payment to {} for {}'.format(
            payment.send_to,
            str('${:,.2f}'.format(payment.amount)))
    }
    resp = api.users.request_sms(payment.authy_id, options)
    if resp.ok():
        flash(resp.content['message'])
        return True
    else:
        flash(resp.errors()['message'])
        return False


def check_sms_auth(authy_id, action, code):
    """
    Validates an one time password (OTP)
    """
    api = get_authy_client()
    try:
        options = {
            'force': True,
            'action': action,
        }
        resp = api.tokens.verify(authy_id, code, options)
        if resp.ok():
            return True
        else:
            flash(resp.errors()['message'])
    except Exception as e:
        flash("Error validating code: {}".format(e))
   
    return False

Note that you must include the same action parameter when sending and checking the token. We're using the payment_id for that.

Next, add a new route for starting the SMS authorization in payments.py and a wrapper function for our validation utility:

@bp.route('/auth/sms', methods=["POST"])
@login_required
def sms_auth():
    if not g.user.authy_id:
        return(redirect(url_for('auth.verify')))
 
    payment_id = request.form['payment_id']
    session['payment_id'] = payment_id
    payment = Payment.query.get(payment_id)
   
    if utils.send_sms_auth(payment):
        return redirect(url_for('auth.verify'))
    else:
        return redirect(url_for('payments.send'))

def check_sms_auth(authy_id, payment_id, action, code):
    """
    Validates an SMS OTP.
    """
    if utils.check_sms_auth(g.user.authy_id, action, code):
        payment = Payment.query.get(payment_id)
        update_payment_status(payment, 'approved')
        return redirect(url_for('payments.list_payments'))
    else:
        abort(400)

Finally we can take advantage of the existing token verification route we used for phone verification in auth.py with a few small changes.

Add an import for our new check_sms_auth function in auth.py:

from payfriend.payment import check_sms_auth

Then update the /verify route to check based on the type of verification. Right now this route only handles phone verification on signup before we've created the Authy user. Therefore we can assume that if the global user has an Authy ID then we can check the SMS authorization instead of the phone verification.

The new /verify route will look like this:


@bp.route('/verify', methods=('GET', 'POST'))
def verify():
    """
    Generic endpoint to verify a code entered by the user.
    """
    form = VerifyForm(request.form)
    validated = form.validate_on_submit()
 
    if form.validate_on_submit():
        email = g.user.email
        (country_code, phone) = utils.parse_phone_number(g.user.phone_number)
        code = form.verification_code.data
 
        # route based on the type of verification
        if not g.user.authy_id:
            if utils.check_verification(country_code, phone, code):
                return handle_verified_user(email, country_code, phone, code)
        else:
            payment_id = session['payment_id']
            action = session['action']
            return check_sms_auth(g.user.authy_id, payment_id, action, code)
 
    return render_template('auth/verify.html', form=form)

Finally, we need to update the client code to allow for SMS authorization. You can handle this many ways, but let's make the Send SMS button appear after 15 seconds if the user hasn't approved the push authorization in auth.js:


var sendPayment = function(form) {
  $.post("/payments/send", form, function(data) {
    if (data.success) {
      $(".auth-ot").fadeIn();
      checkPaymentStatus(data.payment_id);

      // display SMS option after 15 seconds
      setTimeout(function() {
        $("#payment_id").val(data.payment_id);
        $(".auth-sms").fadeIn();
      }, 15000);
    }
  });
};

Nice! Now you have a PSD2 compliant application with two different options for authorization.

Where to next?

The full code is available on my Github. Take a look at this diff to compare the finished solution with the starter solution.

For more details on PSD2 compliance, you can read our explainer on dynamic linking.

If you're a Python developer concerned about security, you might enjoy these other tutorials:

Check out more things you can do with Authy in the API documentation.