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