git clone [email protected]:robinske/payfriend-starter.git && cd payfriend-starter
.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***************************
python3 -m venv venv
. venv/bin/activate
py -3 -m venv venv
venv\Scripts\activate.bat
pip install -r requirements.txt
export FLASK_APP=payfriend
export FLASK_ENV=development
flask run
set FLASK_APP=payfriend
set FLASK_ENV=development
flask run
dict
. For our scenario this could look like:details = { 'Sending to': 'Hermione', 'Transaction amount': '1,000,000', 'Currency': 'Galleons' }
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())
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.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)
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>
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)
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
/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.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); } }); };
ngrok http -bind-tls=false 5000
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.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() session['payment_id'] = payment.id options = { 'force': True, 'action': payment.id, '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, payment_id, code): """ Validates an one time password (OTP) """ api = get_authy_client() try: options = { 'force': True, 'action': payment_id, } 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
action
parameter when sending and checking the token. We're using the payment_id
for that.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, code): """ Validates an SMS OTP. """ if utils.check_sms_auth(g.user.authy_id, payment_id, code): payment = Payment.query.get(payment_id) update_payment_status(payment, 'approved') return redirect(url_for('payments.list_payments')) else: abort(400)
auth.py
with a few small changes.check_sms_auth
function in auth.py
:from payfriend.payment import check_sms_auth
/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./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'] return check_sms_auth(g.user.authy_id, payment_id, code) return render_template('auth/verify.html', form=form)
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); } }); };