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