commit
e35fb631cf
|
@ -11,5 +11,6 @@ from .views import (
|
||||||
facebook,
|
facebook,
|
||||||
change_email,
|
change_email,
|
||||||
mfa,
|
mfa,
|
||||||
|
fido,
|
||||||
social,
|
social,
|
||||||
)
|
)
|
||||||
|
|
66
app/auth/templates/auth/fido.html
Normal file
66
app/auth/templates/auth/fido.html
Normal file
|
@ -0,0 +1,66 @@
|
||||||
|
{% extends "single.html" %}
|
||||||
|
|
||||||
|
|
||||||
|
{% block title %}
|
||||||
|
Verify Your Security Key
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block head %}
|
||||||
|
<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 single_content %}
|
||||||
|
<div class="bg-white p-6" style="margin: auto">
|
||||||
|
|
||||||
|
<div class="mb-2">
|
||||||
|
Your account is protected with your security key (WebAuthn). <br><br>
|
||||||
|
Follow your browser's steps to continue the sign-in process.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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="btnVerifyKey" class="btn btn-success mt-2">Use your security key</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if enable_otp %}
|
||||||
|
<div class="text-center text-muted mb-6" style="margin-top: 1em;">
|
||||||
|
Don't have your key with you? <br> <a href="{{ url_for('auth.mfa') }}">Verify by One-Time Password</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<script>
|
||||||
|
async function verifyKey () {
|
||||||
|
$("#btnVerifyKey").prop('disabled', true);
|
||||||
|
$("#btnVerifyKey").text('Waiting for Security Key...');
|
||||||
|
|
||||||
|
const credentialRequestOptions = transformCredentialRequestOptions(
|
||||||
|
JSON.parse('{{webauthn_assertion_options|tojson|safe}}')
|
||||||
|
)
|
||||||
|
|
||||||
|
let assertion;
|
||||||
|
try {
|
||||||
|
assertion = await navigator.credentials.get({
|
||||||
|
publicKey: credentialRequestOptions
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
toastr.error("An error occurred when we trying to verify your key.");
|
||||||
|
$("#btnVerifyKey").prop('disabled', false);
|
||||||
|
$("#btnVerifyKey").text('Use your security key');
|
||||||
|
return console.error("Error when trying to get credential:", err);
|
||||||
|
}
|
||||||
|
|
||||||
|
const skAssertion = transformAssertionForServer(assertion);
|
||||||
|
$('#sk_assertion').val(JSON.stringify(skAssertion));
|
||||||
|
$('#formRegisterKey').submit();
|
||||||
|
}
|
||||||
|
|
||||||
|
$("#btnVerifyKey").click(verifyKey);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% endblock %}
|
|
@ -28,6 +28,12 @@
|
||||||
<button class="btn btn-success mt-2">Validate</button>
|
<button class="btn btn-success mt-2">Validate</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
{% if enable_fido %}
|
||||||
|
<div class="text-center text-muted mb-6" style="margin-top: 1em;">
|
||||||
|
Having trouble with your authenticator? <br> <a href="{{ url_for('auth.fido') }}">Verify by your security key</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
104
app/auth/views/fido.py
Normal file
104
app/auth/views/fido.py
Normal file
|
@ -0,0 +1,104 @@
|
||||||
|
import json
|
||||||
|
import secrets
|
||||||
|
import webauthn
|
||||||
|
from app.config import RP_ID, URL
|
||||||
|
|
||||||
|
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_enabled())):
|
||||||
|
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")
|
||||||
|
|
||||||
|
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 verification failed. Error: Invalid Payload", "warning")
|
||||||
|
return redirect(url_for("auth.login"))
|
||||||
|
|
||||||
|
challenge = session["fido_challenge"]
|
||||||
|
|
||||||
|
webauthn_assertion_response = webauthn.WebAuthnAssertionResponse(
|
||||||
|
webauthn_user, sk_assertion, challenge, URL, uv_required=False
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
new_sign_count = webauthn_assertion_response.verify()
|
||||||
|
except Exception as e:
|
||||||
|
LOG.error(f"An error occurred in WebAuthn verification process: {e}")
|
||||||
|
flash("Key verification failed.", "warning")
|
||||||
|
else:
|
||||||
|
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"))
|
||||||
|
|
||||||
|
# Prepare information 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,
|
||||||
|
enable_otp=user.enable_otp,
|
||||||
|
)
|
|
@ -14,7 +14,15 @@ def after_login(user, next_url):
|
||||||
If user enables MFA: redirect user to MFA page
|
If user enables MFA: redirect user to MFA page
|
||||||
Otherwise redirect to dashboard page if no next_url
|
Otherwise redirect to dashboard page if no next_url
|
||||||
"""
|
"""
|
||||||
if user.enable_otp:
|
if user.fido_enabled():
|
||||||
|
# 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
|
session[MFA_USER_ID] = user.id
|
||||||
if next_url:
|
if next_url:
|
||||||
return redirect(url_for("auth.mfa", next_url=next_url))
|
return redirect(url_for("auth.mfa", next_url=next_url))
|
||||||
|
|
|
@ -55,4 +55,8 @@ def mfa():
|
||||||
else:
|
else:
|
||||||
flash("Incorrect token", "warning")
|
flash("Incorrect token", "warning")
|
||||||
|
|
||||||
return render_template("auth/mfa.html", otp_token_form=otp_token_form)
|
return render_template(
|
||||||
|
"auth/mfa.html",
|
||||||
|
otp_token_form=otp_token_form,
|
||||||
|
enable_fido=(user.fido_enabled()),
|
||||||
|
)
|
||||||
|
|
|
@ -4,6 +4,7 @@ import string
|
||||||
import subprocess
|
import subprocess
|
||||||
|
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
SHA1 = subprocess.getoutput("git rev-parse HEAD")
|
SHA1 = subprocess.getoutput("git rev-parse HEAD")
|
||||||
ROOT_DIR = os.path.abspath(os.path.dirname(os.path.dirname(__file__)))
|
ROOT_DIR = os.path.abspath(os.path.dirname(os.path.dirname(__file__)))
|
||||||
|
@ -38,6 +39,9 @@ DEBUG = os.environ["DEBUG"] if "DEBUG" in os.environ else False
|
||||||
URL = os.environ["URL"]
|
URL = os.environ["URL"]
|
||||||
print(">>> URL:", URL)
|
print(">>> URL:", URL)
|
||||||
|
|
||||||
|
# Calculate RP_ID for WebAuthn
|
||||||
|
RP_ID = urlparse(URL).hostname
|
||||||
|
|
||||||
SENTRY_DSN = os.environ.get("SENTRY_DSN")
|
SENTRY_DSN = os.environ.get("SENTRY_DSN")
|
||||||
|
|
||||||
# can use another sentry project for the front-end to avoid noises
|
# can use another sentry project for the front-end to avoid noises
|
||||||
|
|
|
@ -11,6 +11,8 @@ from .views import (
|
||||||
alias_contact_manager,
|
alias_contact_manager,
|
||||||
mfa_setup,
|
mfa_setup,
|
||||||
mfa_cancel,
|
mfa_cancel,
|
||||||
|
fido_setup,
|
||||||
|
fido_cancel,
|
||||||
domain_detail,
|
domain_detail,
|
||||||
lifetime_licence,
|
lifetime_licence,
|
||||||
directory,
|
directory,
|
||||||
|
|
27
app/dashboard/templates/dashboard/fido_cancel.html
Normal file
27
app/dashboard/templates/dashboard/fido_cancel.html
Normal file
|
@ -0,0 +1,27 @@
|
||||||
|
{% extends 'default.html' %}
|
||||||
|
{% set active_page = "setting" %}
|
||||||
|
{% block title %}
|
||||||
|
Unlink Security Key
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
|
||||||
|
{% block default_content %}
|
||||||
|
<div class="bg-white p-6" style="max-width: 60em; margin: auto">
|
||||||
|
<h1 class="h2">Unlink Your Security Key</h1>
|
||||||
|
<p>
|
||||||
|
Please enter the password of your account so that we can ensure it's you.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form method="post">
|
||||||
|
{{ password_check_form.csrf_token }}
|
||||||
|
|
||||||
|
<div class="font-weight-bold mt-5">Password</div>
|
||||||
|
|
||||||
|
{{ password_check_form.password(class="form-control", autofocus="true") }}
|
||||||
|
{{ render_field_errors(password_check_form.password) }}
|
||||||
|
<button class="btn btn-lg btn-danger mt-2">Unlink Key</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
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 SimpleLogin</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" 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 %}
|
|
@ -85,18 +85,32 @@
|
||||||
</div>
|
</div>
|
||||||
<!-- END change name & profile picture -->
|
<!-- END change name & profile picture -->
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="card-title">Security Key (WebAuthn)</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
You can secure your account by linking either your FIDO-supported physical key such as Yubikey, Google Titan,
|
||||||
|
or a device with appropriate hardware to your account.
|
||||||
|
</div>
|
||||||
|
{% if current_user.fido_uuid is none %}
|
||||||
|
<a href="{{ url_for('dashboard.fido_setup') }}" class="btn btn-outline-primary">Setup WebAuthn</a>
|
||||||
|
{% else %}
|
||||||
|
<a href="{{ url_for('dashboard.fido_cancel') }}" class="btn btn-outline-danger">Disable WebAuthn</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="card-title">Multi-Factor Authentication (MFA)</div>
|
<div class="card-title">One-Time Password (TOTP)</div>
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
Secure your account with Multi-Factor Authentication. <br>
|
Secure your account with One-Time Password. <br>
|
||||||
This requires having applications like Google Authenticator, Authy, MyDigiPassword, etc.
|
This requires having applications like Google Authenticator, Authy, MyDigiPassword, etc.
|
||||||
</div>
|
</div>
|
||||||
{% if not current_user.enable_otp %}
|
{% if not current_user.enable_otp %}
|
||||||
<a href="{{ url_for('dashboard.mfa_setup') }}" class="btn btn-outline-primary">Enable</a>
|
<a href="{{ url_for('dashboard.mfa_setup') }}" class="btn btn-outline-primary">Setup TOTP</a>
|
||||||
{% else %}
|
{% else %}
|
||||||
<a href="{{ url_for('dashboard.mfa_cancel') }}" class="btn btn-outline-danger">Cancel MFA</a>
|
<a href="{{ url_for('dashboard.mfa_cancel') }}" class="btn btn-outline-danger">Disable TOTP</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
39
app/dashboard/views/fido_cancel.py
Normal file
39
app/dashboard/views/fido_cancel.py
Normal file
|
@ -0,0 +1,39 @@
|
||||||
|
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 not current_user.fido_enabled():
|
||||||
|
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_sign_count = 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
|
||||||
|
)
|
95
app/dashboard/views/fido_setup.py
Normal file
95
app/dashboard/views/fido_setup.py
Normal file
|
@ -0,0 +1,95 @@
|
||||||
|
import uuid
|
||||||
|
import json
|
||||||
|
import secrets
|
||||||
|
import webauthn
|
||||||
|
from app.config import RP_ID, 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_enabled():
|
||||||
|
flash("You have already registered your security key", "warning")
|
||||||
|
return redirect(url_for("dashboard.index"))
|
||||||
|
|
||||||
|
fido_token_form = FidoTokenForm()
|
||||||
|
|
||||||
|
# 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,
|
||||||
|
URL,
|
||||||
|
sk_assertion,
|
||||||
|
challenge,
|
||||||
|
trusted_attestation_cert_required=False,
|
||||||
|
none_attestation_permitted=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
fido_credential = fido_reg_response.verify()
|
||||||
|
except Exception as e:
|
||||||
|
LOG.error(f"An error occurred in WebAuthn registration process: {e}")
|
||||||
|
flash("Key registration failed.", "warning")
|
||||||
|
return redirect(url_for("dashboard.index"))
|
||||||
|
|
||||||
|
current_user.fido_pk = str(fido_credential.public_key, "utf-8")
|
||||||
|
current_user.fido_uuid = fido_uuid
|
||||||
|
current_user.fido_sign_count = fido_credential.sign_count
|
||||||
|
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 information for key registration process
|
||||||
|
fido_uuid = str(uuid.uuid4())
|
||||||
|
challenge = secrets.token_urlsafe(32)
|
||||||
|
|
||||||
|
credential_create_options = webauthn.WebAuthnMakeCredentialOptions(
|
||||||
|
challenge,
|
||||||
|
"SimpleLogin",
|
||||||
|
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("=")
|
||||||
|
|
||||||
|
return render_template(
|
||||||
|
"dashboard/fido_setup.html",
|
||||||
|
fido_token_form=fido_token_form,
|
||||||
|
credential_create_options=registration_dict,
|
||||||
|
)
|
|
@ -134,6 +134,17 @@ 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_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)
|
||||||
|
|
||||||
|
def fido_enabled(self) -> bool:
|
||||||
|
if self.fido_uuid is not None:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
# 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")
|
||||||
|
|
||||||
|
|
|
@ -38,3 +38,4 @@ facebook-sdk
|
||||||
google-api-python-client
|
google-api-python-client
|
||||||
google-auth-httplib2
|
google-auth-httplib2
|
||||||
python-gnupg
|
python-gnupg
|
||||||
|
webauthn
|
|
@ -80,6 +80,7 @@ pycparser==2.19 # via cffi
|
||||||
pycryptodome==3.9.4 # via -r requirements.in
|
pycryptodome==3.9.4 # via -r requirements.in
|
||||||
pygments==2.4.2 # via ipython
|
pygments==2.4.2 # via ipython
|
||||||
pyopenssl==19.0.0 # via -r requirements.in
|
pyopenssl==19.0.0 # via -r requirements.in
|
||||||
|
webauthn==0.4.7 # via manually
|
||||||
pyotp==2.3.0 # via -r requirements.in
|
pyotp==2.3.0 # via -r requirements.in
|
||||||
pyparsing==2.4.0 # via packaging
|
pyparsing==2.4.0 # via packaging
|
||||||
pytest==4.6.3 # via -r requirements.in
|
pytest==4.6.3 # via -r requirements.in
|
||||||
|
|
122
static/assets/js/vendors/base64.js
vendored
Normal file
122
static/assets/js/vendors/base64.js
vendored
Normal file
|
@ -0,0 +1,122 @@
|
||||||
|
// Copyright (c) 2017 Duo Security, Inc. All rights reserved.
|
||||||
|
// Under BSD 3-Clause "New" or "Revised" License
|
||||||
|
// https://github.com/duo-labs/py_webauthn/
|
||||||
|
|
||||||
|
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