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,
|
||||
mfa_setup,
|
||||
mfa_cancel,
|
||||
fido_setup,
|
||||
domain_detail,
|
||||
lifetime_licence,
|
||||
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"
|
||||
)
|
||||
|
||||
# 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")
|
||||
|
||||
|
|
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