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
+
+
+
+
+
+
+
+
+
+{% 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.
+
+
+
+
+
+
+{% 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.
+
+
+
+
+
+
+
+
+
+
+
+{% 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="") }}
-
+