Security key setup page (front-end)
This commit is contained in:
parent
117b120556
commit
3ce4dfb371
|
@ -11,6 +11,7 @@ from .views import (
|
||||||
alias_contact_manager,
|
alias_contact_manager,
|
||||||
mfa_setup,
|
mfa_setup,
|
||||||
mfa_cancel,
|
mfa_cancel,
|
||||||
|
fido_setup,
|
||||||
domain_detail,
|
domain_detail,
|
||||||
lifetime_licence,
|
lifetime_licence,
|
||||||
directory,
|
directory,
|
||||||
|
|
58
app/dashboard/templates/dashboard/fido_setup.html
Normal file
58
app/dashboard/templates/dashboard/fido_setup.html
Normal file
|
@ -0,0 +1,58 @@
|
||||||
|
{% extends 'default.html' %}
|
||||||
|
{% set active_page = "setting" %}
|
||||||
|
{% block title %}
|
||||||
|
Security Key Setup
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block head %}
|
||||||
|
<script src="{{ url_for('static', filename='node_modules/qrious/dist/qrious.min.js') }}"></script>
|
||||||
|
<script src="{{ url_for('static', filename='assets/js/vendors/base64.js') }}"></script>
|
||||||
|
<script src="{{ url_for('static', filename='assets/js/vendors/webauthn.js') }}"></script>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block default_content %}
|
||||||
|
<div class="bg-white p-6" style="max-width: 60em; margin: auto">
|
||||||
|
<h1 class="h2 text-center">Register Your Security Key</h1>
|
||||||
|
<p class="text-center">Follow your browser's steps to register your security key with Simple Login</p>
|
||||||
|
|
||||||
|
<form id="formRegisterKey" method="post">
|
||||||
|
{{ fido_token_form.csrf_token }}
|
||||||
|
{{ fido_token_form.sk_assertion(class="form-control", placeholder="") }}
|
||||||
|
</form>
|
||||||
|
<div class="text-center">
|
||||||
|
<button id="btnRegisterKey" onclick="registerKey($, toastr)" class="btn btn-lg btn-primary mt-2">Register Key</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
async function registerKey () {
|
||||||
|
$("#btnRegisterKey").prop('disabled', true);
|
||||||
|
$("#btnRegisterKey").text('Waiting for Security Key...');
|
||||||
|
|
||||||
|
const pkCredentialCreateOptions = transformCredentialCreateOptions(
|
||||||
|
JSON.parse('{{credential_create_options|tojson|safe}}')
|
||||||
|
)
|
||||||
|
|
||||||
|
let credential
|
||||||
|
try {
|
||||||
|
credential = await navigator.credentials.create({
|
||||||
|
publicKey: pkCredentialCreateOptions
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
toastr.error("An error occurred when we trying to register your key.");
|
||||||
|
$("#btnRegisterKey").prop('disabled', false);
|
||||||
|
$("#btnRegisterKey").text('Register Key');
|
||||||
|
return console.error("Error when trying to create credential:", err);
|
||||||
|
}
|
||||||
|
|
||||||
|
const skAssertion = transformNewAssertionForServer(credential);
|
||||||
|
|
||||||
|
$('#sk_assertion').val(JSON.stringify(skAssertion));
|
||||||
|
$('#formRegisterKey').submit();
|
||||||
|
}
|
||||||
|
|
||||||
|
$("#btnRegisterKey").click(registerKey);
|
||||||
|
$('document').ready(registerKey());
|
||||||
|
</script>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
62
app/dashboard/views/fido_setup.py
Normal file
62
app/dashboard/views/fido_setup.py
Normal file
|
@ -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
|
||||||
|
)
|
|
@ -134,6 +134,10 @@ class User(db.Model, ModelMixin, UserMixin):
|
||||||
db.Boolean, nullable=False, default=False, server_default="0"
|
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
|
# some users could have lifetime premium
|
||||||
lifetime = db.Column(db.Boolean, default=False, nullable=False, server_default="0")
|
lifetime = db.Column(db.Boolean, default=False, nullable=False, server_default="0")
|
||||||
|
|
||||||
|
|
118
static/assets/js/vendors/base64.js
vendored
Normal file
118
static/assets/js/vendors/base64.js
vendored
Normal file
|
@ -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))
|
131
static/assets/js/vendors/webauthn.js
vendored
Normal file
131
static/assets/js/vendors/webauthn.js
vendored
Normal file
|
@ -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),
|
||||||
|
};
|
||||||
|
};
|
Loading…
Reference in a new issue