From 117b120556a8aa2beaf1d322fbb3acf242e18a62 Mon Sep 17 00:00:00 2001 From: devStorm <59678453+developStorm@users.noreply.github.com> Date: Tue, 5 May 2020 01:31:19 -0700 Subject: [PATCH 01/22] Add python dependency webauthn --- requirements.in | 3 ++- requirements.txt | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/requirements.in b/requirements.in index 3c3712c6..cbd7def9 100644 --- a/requirements.in +++ b/requirements.in @@ -37,4 +37,5 @@ flask_profiler facebook-sdk google-api-python-client google-auth-httplib2 -python-gnupg \ No newline at end of file +python-gnupg +webauthn \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 1c67e09c..b627b206 100644 --- a/requirements.txt +++ b/requirements.txt @@ -80,6 +80,7 @@ pycparser==2.19 # via cffi pycryptodome==3.9.4 # via -r requirements.in pygments==2.4.2 # via ipython pyopenssl==19.0.0 # via -r requirements.in +webauthn==0.4.7 # via manually pyotp==2.3.0 # via -r requirements.in pyparsing==2.4.0 # via packaging pytest==4.6.3 # via -r requirements.in From 3ce4dfb371e68a181ae102f308fc789ae9673756 Mon Sep 17 00:00:00 2001 From: devStorm <59678453+developStorm@users.noreply.github.com> Date: Tue, 5 May 2020 01:32:49 -0700 Subject: [PATCH 02/22] Security key setup page (front-end) --- app/dashboard/__init__.py | 1 + .../templates/dashboard/fido_setup.html | 58 ++++++++ app/dashboard/views/fido_setup.py | 62 +++++++++ app/models.py | 4 + static/assets/js/vendors/base64.js | 118 ++++++++++++++++ static/assets/js/vendors/webauthn.js | 131 ++++++++++++++++++ 6 files changed, 374 insertions(+) create mode 100644 app/dashboard/templates/dashboard/fido_setup.html create mode 100644 app/dashboard/views/fido_setup.py create mode 100644 static/assets/js/vendors/base64.js create mode 100644 static/assets/js/vendors/webauthn.js diff --git a/app/dashboard/__init__.py b/app/dashboard/__init__.py index e493b495..aaa5e169 100644 --- a/app/dashboard/__init__.py +++ b/app/dashboard/__init__.py @@ -11,6 +11,7 @@ from .views import ( alias_contact_manager, mfa_setup, mfa_cancel, + fido_setup, domain_detail, lifetime_licence, directory, diff --git a/app/dashboard/templates/dashboard/fido_setup.html b/app/dashboard/templates/dashboard/fido_setup.html new file mode 100644 index 00000000..713c29b2 --- /dev/null +++ b/app/dashboard/templates/dashboard/fido_setup.html @@ -0,0 +1,58 @@ +{% extends 'default.html' %} +{% set active_page = "setting" %} +{% block title %} + Security Key Setup +{% endblock %} + +{% block head %} + + + +{% endblock %} + +{% block default_content %} +
+

Register Your Security Key

+

Follow your browser's steps to register your security key with Simple Login

+ +
+ {{ fido_token_form.csrf_token }} + {{ fido_token_form.sk_assertion(class="form-control", placeholder="") }} +
+
+ +
+ + + +
+{% endblock %} diff --git a/app/dashboard/views/fido_setup.py b/app/dashboard/views/fido_setup.py new file mode 100644 index 00000000..ec08edb1 --- /dev/null +++ b/app/dashboard/views/fido_setup.py @@ -0,0 +1,62 @@ +import uuid +import json +import secrets +import webauthn +from app.config import URL as SITE_URL +from urllib.parse import urlparse + +from flask import render_template, flash, redirect, url_for, session +from flask_login import login_required, current_user +from flask_wtf import FlaskForm +from wtforms import HiddenField, validators + +from app.dashboard.base import dashboard_bp +from app.extensions import db +from app.log import LOG + + +class FidoTokenForm(FlaskForm): + sk_assertion = HiddenField("sk_assertion", validators=[validators.DataRequired()]) + + +@dashboard_bp.route("/fido_setup", methods=["GET", "POST"]) +@login_required +def fido_setup(): + if current_user.fido_uuid is not None: + flash("You have already registered your security key", "warning") + return redirect(url_for("dashboard.index")) + + fido_token_form = FidoTokenForm() + + # Prepare infomation for key registration process + rp_id = urlparse(SITE_URL).hostname + fido_uuid = str(uuid.uuid4()) + challenge = secrets.token_urlsafe(32) + + credential_create_options = webauthn.WebAuthnMakeCredentialOptions( + challenge, 'Simple Login', rp_id, fido_uuid, + current_user.email, current_user.name, False, attestation='none') + + # Don't think this one should be used, but it's not configurable by arguments + # https://www.w3.org/TR/webauthn/#sctn-location-extension + registration_dict = credential_create_options.registration_dict + del registration_dict['extensions']['webauthn.loc'] + + session['fido_uuid'] = fido_uuid + session['fido_challenge'] = challenge.rstrip('=') + + if fido_token_form.validate_on_submit(): + sk_assertion = fido_token_form.sk_assertion.data + LOG.d(sk_assertion) + # if totp.verify(token): + # current_user.enable_otp = True + # db.session.commit() + # flash("Security key has been activated", "success") + # return redirect(url_for("dashboard.index")) + # else: + # flash("Incorrect challenge", "warning") + + return render_template( + "dashboard/fido_setup.html", fido_token_form=fido_token_form, + credential_create_options=registration_dict + ) diff --git a/app/models.py b/app/models.py index 761b3a5f..f6fc6b6d 100644 --- a/app/models.py +++ b/app/models.py @@ -134,6 +134,10 @@ class User(db.Model, ModelMixin, UserMixin): db.Boolean, nullable=False, default=False, server_default="0" ) + # Fields for WebAuthn + fido_uuid = db.Column(db.String(), nullable=True, unique=True) + fido_pk = db.Column(db.String(), nullable=True) + # some users could have lifetime premium lifetime = db.Column(db.Boolean, default=False, nullable=False, server_default="0") diff --git a/static/assets/js/vendors/base64.js b/static/assets/js/vendors/base64.js new file mode 100644 index 00000000..3ebe0a92 --- /dev/null +++ b/static/assets/js/vendors/base64.js @@ -0,0 +1,118 @@ +var lookup = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/' + +;(function (exports) { + 'use strict' + + var Arr = (typeof Uint8Array !== 'undefined') + ? Uint8Array + : Array + + var PLUS = '+'.charCodeAt(0) + var SLASH = '/'.charCodeAt(0) + var NUMBER = '0'.charCodeAt(0) + var LOWER = 'a'.charCodeAt(0) + var UPPER = 'A'.charCodeAt(0) + var PLUS_URL_SAFE = '-'.charCodeAt(0) + var SLASH_URL_SAFE = '_'.charCodeAt(0) + + function decode (elt) { + var code = elt.charCodeAt(0) + if (code === PLUS || code === PLUS_URL_SAFE) return 62 // '+' + if (code === SLASH || code === SLASH_URL_SAFE) return 63 // '/' + if (code < NUMBER) return -1 // no match + if (code < NUMBER + 10) return code - NUMBER + 26 + 26 + if (code < UPPER + 26) return code - UPPER + if (code < LOWER + 26) return code - LOWER + 26 + } + + function b64ToByteArray (b64) { + var i, j, l, tmp, placeHolders, arr + + if (b64.length % 4 > 0) { + throw new Error('Invalid string. Length must be a multiple of 4') + } + + // the number of equal signs (place holders) + // if there are two placeholders, than the two characters before it + // represent one byte + // if there is only one, then the three characters before it represent 2 bytes + // this is just a cheap hack to not do indexOf twice + var len = b64.length + placeHolders = b64.charAt(len - 2) === '=' ? 2 : b64.charAt(len - 1) === '=' ? 1 : 0 + + // base64 is 4/3 + up to two characters of the original data + arr = new Arr(b64.length * 3 / 4 - placeHolders) + + // if there are placeholders, only get up to the last complete 4 chars + l = placeHolders > 0 ? b64.length - 4 : b64.length + + var L = 0 + + function push (v) { + arr[L++] = v + } + + for (i = 0, j = 0; i < l; i += 4, j += 3) { + tmp = (decode(b64.charAt(i)) << 18) | (decode(b64.charAt(i + 1)) << 12) | (decode(b64.charAt(i + 2)) << 6) | decode(b64.charAt(i + 3)) + push((tmp & 0xFF0000) >> 16) + push((tmp & 0xFF00) >> 8) + push(tmp & 0xFF) + } + + if (placeHolders === 2) { + tmp = (decode(b64.charAt(i)) << 2) | (decode(b64.charAt(i + 1)) >> 4) + push(tmp & 0xFF) + } else if (placeHolders === 1) { + tmp = (decode(b64.charAt(i)) << 10) | (decode(b64.charAt(i + 1)) << 4) | (decode(b64.charAt(i + 2)) >> 2) + push((tmp >> 8) & 0xFF) + push(tmp & 0xFF) + } + + return arr + } + + function uint8ToBase64 (uint8) { + var i + var extraBytes = uint8.length % 3 // if we have 1 byte left, pad 2 bytes + var output = '' + var temp, length + + function encode (num) { + return lookup.charAt(num) + } + + function tripletToBase64 (num) { + return encode(num >> 18 & 0x3F) + encode(num >> 12 & 0x3F) + encode(num >> 6 & 0x3F) + encode(num & 0x3F) + } + + // go through the array every three bytes, we'll deal with trailing stuff later + for (i = 0, length = uint8.length - extraBytes; i < length; i += 3) { + temp = (uint8[i] << 16) + (uint8[i + 1] << 8) + (uint8[i + 2]) + output += tripletToBase64(temp) + } + + // pad the end with zeros, but make sure to not forget the extra bytes + switch (extraBytes) { + case 1: + temp = uint8[uint8.length - 1] + output += encode(temp >> 2) + output += encode((temp << 4) & 0x3F) + output += '==' + break + case 2: + temp = (uint8[uint8.length - 2] << 8) + (uint8[uint8.length - 1]) + output += encode(temp >> 10) + output += encode((temp >> 4) & 0x3F) + output += encode((temp << 2) & 0x3F) + output += '=' + break + default: + break + } + + return output + } + + exports.toByteArray = b64ToByteArray + exports.fromByteArray = uint8ToBase64 +}(typeof exports === 'undefined' ? (this.base64js = {}) : exports)) \ No newline at end of file diff --git a/static/assets/js/vendors/webauthn.js b/static/assets/js/vendors/webauthn.js new file mode 100644 index 00000000..85a8ac22 --- /dev/null +++ b/static/assets/js/vendors/webauthn.js @@ -0,0 +1,131 @@ +// Copyright (c) 2017 Duo Security, Inc. All rights reserved. +// Under BSD 3-Clause "New" or "Revised" License +// https://github.com/duo-labs/py_webauthn/ + +function b64enc(buf) { + return base64js + .fromByteArray(buf) + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=/g, ""); +} + +function b64RawEnc(buf) { + return base64js.fromByteArray(buf).replace(/\+/g, "-").replace(/\//g, "_"); +} + +function hexEncode(buf) { + return Array.from(buf) + .map(function (x) { + return ("0" + x.toString(16)).substr(-2); + }) + .join(""); +} + +const transformCredentialRequestOptions = ( + credentialRequestOptionsFromServer +) => { + let { challenge, allowCredentials } = credentialRequestOptionsFromServer; + + challenge = Uint8Array.from( + atob(challenge.replace(/\_/g, "/").replace(/\-/g, "+")), + (c) => c.charCodeAt(0) + ); + + allowCredentials = allowCredentials.map((credentialDescriptor) => { + let { id } = credentialDescriptor; + id = id.replace(/\_/g, "/").replace(/\-/g, "+"); + id = Uint8Array.from(atob(id), (c) => c.charCodeAt(0)); + return Object.assign({}, credentialDescriptor, { id }); + }); + + const transformedCredentialRequestOptions = Object.assign( + {}, + credentialRequestOptionsFromServer, + { challenge, allowCredentials } + ); + + return transformedCredentialRequestOptions; +}; + +/** + * Transforms items in the credentialCreateOptions generated on the server + * into byte arrays expected by the navigator.credentials.create() call + * @param {Object} credentialCreateOptionsFromServer + */ +const transformCredentialCreateOptions = ( + credentialCreateOptionsFromServer +) => { + let { challenge, user } = credentialCreateOptionsFromServer; + user.id = Uint8Array.from( + atob( + credentialCreateOptionsFromServer.user.id + .replace(/\_/g, "/") + .replace(/\-/g, "+") + ), + (c) => c.charCodeAt(0) + ); + + challenge = Uint8Array.from( + atob( + credentialCreateOptionsFromServer.challenge + .replace(/\_/g, "/") + .replace(/\-/g, "+") + ), + (c) => c.charCodeAt(0) + ); + + const transformedCredentialCreateOptions = Object.assign( + {}, + credentialCreateOptionsFromServer, + { challenge, user } + ); + + return transformedCredentialCreateOptions; +}; + + +/** + * Transforms the binary data in the credential into base64 strings + * for posting to the server. + * @param {PublicKeyCredential} newAssertion + */ +const transformNewAssertionForServer = (newAssertion) => { + const attObj = new Uint8Array(newAssertion.response.attestationObject); + const clientDataJSON = new Uint8Array(newAssertion.response.clientDataJSON); + const rawId = new Uint8Array(newAssertion.rawId); + + const registrationClientExtensions = newAssertion.getClientExtensionResults(); + + return { + id: newAssertion.id, + rawId: b64enc(rawId), + type: newAssertion.type, + attObj: b64enc(attObj), + clientData: b64enc(clientDataJSON), + registrationClientExtensions: JSON.stringify(registrationClientExtensions), + }; +}; + + +/** + * Encodes the binary data in the assertion into strings for posting to the server. + * @param {PublicKeyCredential} newAssertion + */ +const transformAssertionForServer = (newAssertion) => { + const authData = new Uint8Array(newAssertion.response.authenticatorData); + const clientDataJSON = new Uint8Array(newAssertion.response.clientDataJSON); + const rawId = new Uint8Array(newAssertion.rawId); + const sig = new Uint8Array(newAssertion.response.signature); + const assertionClientExtensions = newAssertion.getClientExtensionResults(); + + return { + id: newAssertion.id, + rawId: b64enc(rawId), + type: newAssertion.type, + authData: b64RawEnc(authData), + clientData: b64RawEnc(clientDataJSON), + signature: hexEncode(sig), + assertionClientExtensions: JSON.stringify(assertionClientExtensions), + }; +}; \ No newline at end of file From a32b69078f565a9e1b8641520fb653a0f778cd37 Mon Sep 17 00:00:00 2001 From: devStorm <59678453+developStorm@users.noreply.github.com> Date: Tue, 5 May 2020 01:58:42 -0700 Subject: [PATCH 03/22] Key registration (Backend) --- app/dashboard/views/fido_setup.py | 48 +++++++++++++++++++++++-------- app/models.py | 3 +- 2 files changed, 38 insertions(+), 13 deletions(-) diff --git a/app/dashboard/views/fido_setup.py b/app/dashboard/views/fido_setup.py index ec08edb1..f1f2581b 100644 --- a/app/dashboard/views/fido_setup.py +++ b/app/dashboard/views/fido_setup.py @@ -27,9 +27,44 @@ def fido_setup(): return redirect(url_for("dashboard.index")) fido_token_form = FidoTokenForm() + + rp_id = urlparse(SITE_URL).hostname + + # Handling POST requests + if fido_token_form.validate_on_submit(): + try: + sk_assertion = json.loads(fido_token_form.sk_assertion.data) + except Exception as e: + flash('Key registration failed. Error: Invalid Payload', "warning") + return redirect(url_for("dashboard.index")) + + fido_uuid = session['fido_uuid'] + challenge = session['fido_challenge'] + + fido_reg_response = webauthn.WebAuthnRegistrationResponse( + rp_id, + SITE_URL, + sk_assertion, + challenge, + trusted_attestation_cert_required = False, + none_attestation_permitted = True) + + try: + fido_credential = fido_reg_response.verify() + except Exception as e: + flash('Key registration failed. Error: {}'.format(e), "warning") + return redirect(url_for("dashboard.index")) + + current_user.fido_pk = fido_uuid + current_user.fido_uuid = str(fido_credential.public_key, "utf-8") + current_user.fido_credential_id = str(fido_credential.credential_id, "utf-8") + db.session.commit() + + flash("Security key has been activated", "success") + + return redirect(url_for("dashboard.index")) # Prepare infomation for key registration process - rp_id = urlparse(SITE_URL).hostname fido_uuid = str(uuid.uuid4()) challenge = secrets.token_urlsafe(32) @@ -45,17 +80,6 @@ def fido_setup(): session['fido_uuid'] = fido_uuid session['fido_challenge'] = challenge.rstrip('=') - if fido_token_form.validate_on_submit(): - sk_assertion = fido_token_form.sk_assertion.data - LOG.d(sk_assertion) - # if totp.verify(token): - # current_user.enable_otp = True - # db.session.commit() - # flash("Security key has been activated", "success") - # return redirect(url_for("dashboard.index")) - # else: - # flash("Incorrect challenge", "warning") - return render_template( "dashboard/fido_setup.html", fido_token_form=fido_token_form, credential_create_options=registration_dict diff --git a/app/models.py b/app/models.py index f6fc6b6d..839a6ca5 100644 --- a/app/models.py +++ b/app/models.py @@ -136,7 +136,8 @@ class User(db.Model, ModelMixin, UserMixin): # Fields for WebAuthn fido_uuid = db.Column(db.String(), nullable=True, unique=True) - fido_pk = db.Column(db.String(), nullable=True) + fido_credential_id = db.Column(db.String(), nullable=True, unique=True) + fido_pk = db.Column(db.String(), nullable=True, unique=True) # some users could have lifetime premium lifetime = db.Column(db.Boolean, default=False, nullable=False, server_default="0") From 705941b8b8f1f62c6a69e74179213f56b97e3318 Mon Sep 17 00:00:00 2001 From: devStorm <59678453+developStorm@users.noreply.github.com> Date: Tue, 5 May 2020 02:20:52 -0700 Subject: [PATCH 04/22] Unlink security key --- app/dashboard/__init__.py | 1 + .../templates/dashboard/fido_cancel.html | 27 ++++++++++++++ app/dashboard/views/fido_cancel.py | 36 +++++++++++++++++++ 3 files changed, 64 insertions(+) create mode 100644 app/dashboard/templates/dashboard/fido_cancel.html create mode 100644 app/dashboard/views/fido_cancel.py diff --git a/app/dashboard/__init__.py b/app/dashboard/__init__.py index aaa5e169..38632954 100644 --- a/app/dashboard/__init__.py +++ b/app/dashboard/__init__.py @@ -12,6 +12,7 @@ from .views import ( mfa_setup, mfa_cancel, fido_setup, + fido_cancel, domain_detail, lifetime_licence, directory, diff --git a/app/dashboard/templates/dashboard/fido_cancel.html b/app/dashboard/templates/dashboard/fido_cancel.html new file mode 100644 index 00000000..f9696bb5 --- /dev/null +++ b/app/dashboard/templates/dashboard/fido_cancel.html @@ -0,0 +1,27 @@ +{% extends 'default.html' %} +{% set active_page = "setting" %} +{% block title %} + Unlink Security Key +{% endblock %} + + +{% block default_content %} +
+

Unlink Your Security Key

+

+ Please enter the password of your account so that we can ensure it's you. +

+ +
+ {{ password_check_form.csrf_token }} + +
Password
+ + {{ password_check_form.password(class="form-control", autofocus="true") }} + {{ render_field_errors(password_check_form.password) }} + +
+ + +
+{% endblock %} \ No newline at end of file diff --git a/app/dashboard/views/fido_cancel.py b/app/dashboard/views/fido_cancel.py new file mode 100644 index 00000000..14d1fd1c --- /dev/null +++ b/app/dashboard/views/fido_cancel.py @@ -0,0 +1,36 @@ +from flask import render_template, flash, redirect, url_for +from flask_login import login_required, current_user +from flask_wtf import FlaskForm +from wtforms import PasswordField, validators + +from app.dashboard.base import dashboard_bp +from app.extensions import db + + +class LoginForm(FlaskForm): + password = PasswordField("Password", validators=[validators.DataRequired()]) + + +@dashboard_bp.route("/fido_cancel", methods=["GET", "POST"]) +@login_required +def fido_cancel(): + if current_user.fido_uuid is None: + flash("You haven't registed a security key", "warning") + return redirect(url_for("dashboard.index")) + + password_check_form = LoginForm() + + if password_check_form.validate_on_submit(): + password = password_check_form.password.data + + if current_user.check_password(password): + current_user.fido_pk = None + current_user.fido_uuid = None + current_user.fido_credential_id = None + db.session.commit() + flash("We've unlinked your security key.", "success") + return redirect(url_for("dashboard.index")) + else: + flash("Incorrect password", "warning") + + return render_template("dashboard/fido_cancel.html", password_check_form=password_check_form) From 334cc980380bfb0a46f82fc3a74df9103cf68f90 Mon Sep 17 00:00:00 2001 From: devStorm <59678453+developStorm@users.noreply.github.com> Date: Tue, 5 May 2020 03:16:09 -0700 Subject: [PATCH 05/22] Store sign count --- app/auth/views/login_utils.py | 10 +++++++++- app/dashboard/views/fido_cancel.py | 1 + app/dashboard/views/fido_setup.py | 1 + 3 files changed, 11 insertions(+), 1 deletion(-) diff --git a/app/auth/views/login_utils.py b/app/auth/views/login_utils.py index d24a121b..28e5bcc9 100644 --- a/app/auth/views/login_utils.py +++ b/app/auth/views/login_utils.py @@ -14,7 +14,15 @@ def after_login(user, next_url): If user enables MFA: redirect user to MFA page Otherwise redirect to dashboard page if no next_url """ - if user.enable_otp: + if user.fido_uuid is not None: + # Use the same session for FIDO so that we can easily + # switch between these two 2FA option + session[MFA_USER_ID] = user.id + if next_url: + return redirect(url_for("auth.fido", next_url=next_url)) + else: + return redirect(url_for("auth.fido")) + elif user.enable_otp: session[MFA_USER_ID] = user.id if next_url: return redirect(url_for("auth.mfa", next_url=next_url)) diff --git a/app/dashboard/views/fido_cancel.py b/app/dashboard/views/fido_cancel.py index 14d1fd1c..44e64f5f 100644 --- a/app/dashboard/views/fido_cancel.py +++ b/app/dashboard/views/fido_cancel.py @@ -26,6 +26,7 @@ def fido_cancel(): if current_user.check_password(password): current_user.fido_pk = None current_user.fido_uuid = None + current_user.fido_sign_count = None current_user.fido_credential_id = None db.session.commit() flash("We've unlinked your security key.", "success") diff --git a/app/dashboard/views/fido_setup.py b/app/dashboard/views/fido_setup.py index f1f2581b..399b26e4 100644 --- a/app/dashboard/views/fido_setup.py +++ b/app/dashboard/views/fido_setup.py @@ -57,6 +57,7 @@ def fido_setup(): current_user.fido_pk = fido_uuid current_user.fido_uuid = str(fido_credential.public_key, "utf-8") + current_user.fido_sign_count = fido_credential.sign_count current_user.fido_credential_id = str(fido_credential.credential_id, "utf-8") db.session.commit() From 286b1143caa63827c97e577d0d16da78087e85b8 Mon Sep 17 00:00:00 2001 From: devStorm <59678453+developStorm@users.noreply.github.com> Date: Tue, 5 May 2020 03:16:52 -0700 Subject: [PATCH 06/22] Store sign count --- app/auth/views/login_utils.py | 10 +++++++++- app/dashboard/views/fido_cancel.py | 1 + app/dashboard/views/fido_setup.py | 1 + app/models.py | 1 + 4 files changed, 12 insertions(+), 1 deletion(-) diff --git a/app/auth/views/login_utils.py b/app/auth/views/login_utils.py index d24a121b..28e5bcc9 100644 --- a/app/auth/views/login_utils.py +++ b/app/auth/views/login_utils.py @@ -14,7 +14,15 @@ def after_login(user, next_url): If user enables MFA: redirect user to MFA page Otherwise redirect to dashboard page if no next_url """ - if user.enable_otp: + if user.fido_uuid is not None: + # Use the same session for FIDO so that we can easily + # switch between these two 2FA option + session[MFA_USER_ID] = user.id + if next_url: + return redirect(url_for("auth.fido", next_url=next_url)) + else: + return redirect(url_for("auth.fido")) + elif user.enable_otp: session[MFA_USER_ID] = user.id if next_url: return redirect(url_for("auth.mfa", next_url=next_url)) diff --git a/app/dashboard/views/fido_cancel.py b/app/dashboard/views/fido_cancel.py index 14d1fd1c..44e64f5f 100644 --- a/app/dashboard/views/fido_cancel.py +++ b/app/dashboard/views/fido_cancel.py @@ -26,6 +26,7 @@ def fido_cancel(): if current_user.check_password(password): current_user.fido_pk = None current_user.fido_uuid = None + current_user.fido_sign_count = None current_user.fido_credential_id = None db.session.commit() flash("We've unlinked your security key.", "success") diff --git a/app/dashboard/views/fido_setup.py b/app/dashboard/views/fido_setup.py index f1f2581b..399b26e4 100644 --- a/app/dashboard/views/fido_setup.py +++ b/app/dashboard/views/fido_setup.py @@ -57,6 +57,7 @@ def fido_setup(): current_user.fido_pk = fido_uuid current_user.fido_uuid = str(fido_credential.public_key, "utf-8") + current_user.fido_sign_count = fido_credential.sign_count current_user.fido_credential_id = str(fido_credential.credential_id, "utf-8") db.session.commit() diff --git a/app/models.py b/app/models.py index 839a6ca5..52fcf490 100644 --- a/app/models.py +++ b/app/models.py @@ -138,6 +138,7 @@ class User(db.Model, ModelMixin, UserMixin): fido_uuid = db.Column(db.String(), nullable=True, unique=True) fido_credential_id = db.Column(db.String(), nullable=True, unique=True) fido_pk = db.Column(db.String(), nullable=True, unique=True) + fido_sign_count = db.Column(db.Integer(), nullable=True) # some users could have lifetime premium lifetime = db.Column(db.Boolean, default=False, nullable=False, server_default="0") From 650d6e35f048de9ba9d93e0d12dd0111e91798b8 Mon Sep 17 00:00:00 2001 From: devStorm <59678453+developStorm@users.noreply.github.com> Date: Tue, 5 May 2020 05:03:29 -0700 Subject: [PATCH 07/22] FIDO login middleware --- app/auth/__init__.py | 1 + app/auth/templates/auth/fido.html | 60 ++++++++++ app/auth/views/fido.py | 105 ++++++++++++++++++ .../templates/dashboard/fido_setup.html | 2 +- app/dashboard/views/fido_setup.py | 4 +- 5 files changed, 169 insertions(+), 3 deletions(-) create mode 100644 app/auth/templates/auth/fido.html create mode 100644 app/auth/views/fido.py diff --git a/app/auth/__init__.py b/app/auth/__init__.py index 322cbd81..6a51b706 100644 --- a/app/auth/__init__.py +++ b/app/auth/__init__.py @@ -11,5 +11,6 @@ from .views import ( facebook, change_email, mfa, + fido, social, ) diff --git a/app/auth/templates/auth/fido.html b/app/auth/templates/auth/fido.html new file mode 100644 index 00000000..abde9d4f --- /dev/null +++ b/app/auth/templates/auth/fido.html @@ -0,0 +1,60 @@ +{% extends "single.html" %} + + +{% block title %} + Verify Your Security Key +{% endblock %} + +{% block head %} + + +{% endblock %} + +{% block single_content %} +
+ +
+ Your account is protected with your security key (WebAuthn).

+ Follow your browser's steps to continue the sign-in process. +
+ +
+ {{ fido_token_form.csrf_token }} + {{ fido_token_form.sk_assertion(class="form-control", placeholder="") }} +
+
+ +
+ + + +
+ +{% endblock %} \ No newline at end of file diff --git a/app/auth/views/fido.py b/app/auth/views/fido.py new file mode 100644 index 00000000..e4ef5ddd --- /dev/null +++ b/app/auth/views/fido.py @@ -0,0 +1,105 @@ +import json +import secrets +import webauthn +from app.config import URL as SITE_URL +from urllib.parse import urlparse + +from flask import request, render_template, redirect, url_for, flash, session +from flask_login import login_user +from flask_wtf import FlaskForm +from wtforms import HiddenField, validators + +from app.auth.base import auth_bp +from app.config import MFA_USER_ID +from app.log import LOG +from app.models import User +from app.extensions import db + + +class FidoTokenForm(FlaskForm): + sk_assertion = HiddenField("sk_assertion", validators=[validators.DataRequired()]) + + +@auth_bp.route("/fido", methods=["GET", "POST"]) +def fido(): + # passed from login page + user_id = session.get(MFA_USER_ID) + + # user access this page directly without passing by login page + if not user_id: + flash("Unknown error, redirect back to main page", "warning") + return redirect(url_for("auth.login")) + + user = User.get(user_id) + + if not (user and (user.fido_uuid is not None)): + flash("Only user with security key linked should go to this page", "warning") + return redirect(url_for("auth.login")) + + fido_token_form = FidoTokenForm() + + next_url = request.args.get("next") + + rp_id = urlparse(SITE_URL).hostname + + webauthn_user = webauthn.WebAuthnUser( + user.fido_uuid, user.email, user.name, False, + user.fido_credential_id, user.fido_pk, user.fido_sign_count, rp_id) + + # Handling POST requests + if fido_token_form.validate_on_submit(): + try: + sk_assertion = json.loads(fido_token_form.sk_assertion.data) + except Exception as e: + flash('Key registration failed. Error: Invalid Payload', "warning") + return redirect(url_for("dashboard.index")) + + challenge = session['fido_challenge'] + credential_id = sk_assertion['id'] + + webauthn_assertion_response = webauthn.WebAuthnAssertionResponse( + webauthn_user, + sk_assertion, + challenge, + SITE_URL, + uv_required=False + ) + + new_sign_count = False + new_sign_count = webauthn_assertion_response.verify() + try: + pass + except Exception as e: + flash('Key verification failed. Error: {}'.format(e), "warning") + + if new_sign_count != False: + user.fido_sign_count = new_sign_count + db.session.commit() + del session[MFA_USER_ID] + + login_user(user) + flash(f"Welcome back {user.name}!", "success") + + # User comes to login page from another page + if next_url: + LOG.debug("redirect user to %s", next_url) + return redirect(next_url) + else: + LOG.debug("redirect user to dashboard") + return redirect(url_for("dashboard.index")) + else: + # Verification failed, put else here to make structure clear + pass + + # Prepare infomation for key registration process + session.pop('challenge', None) + challenge = secrets.token_urlsafe(32) + + session['fido_challenge'] = challenge.rstrip('=') + + webauthn_assertion_options = webauthn.WebAuthnAssertionOptions( + webauthn_user, challenge) + webauthn_assertion_options = webauthn_assertion_options.assertion_dict + + return render_template("auth/fido.html", fido_token_form=fido_token_form, + webauthn_assertion_options=webauthn_assertion_options) \ No newline at end of file diff --git a/app/dashboard/templates/dashboard/fido_setup.html b/app/dashboard/templates/dashboard/fido_setup.html index 713c29b2..3c8b66e5 100644 --- a/app/dashboard/templates/dashboard/fido_setup.html +++ b/app/dashboard/templates/dashboard/fido_setup.html @@ -20,7 +20,7 @@ {{ fido_token_form.sk_assertion(class="form-control", placeholder="") }}
- +