Merge pull request #199 from developStorm/webauthn-multiple-keys

Support Multiple Keys for WebAuthn
This commit is contained in:
Son Nguyen Kim 2020-05-24 18:56:42 +02:00 committed by GitHub
commit eb60028b1f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 295 additions and 92 deletions

View File

@ -22,10 +22,12 @@
<form id="formRegisterKey" method="post">
{{ fido_token_form.csrf_token }}
{{ fido_token_form.sk_assertion(class="form-control", placeholder="") }}
<div class="text-center">
<button id="btnVerifyKey" class="btn btn-success mt-2" onclick="verifyKey();">Use your security key</button>
</div>
</form>
<div class="text-center">
<button id="btnVerifyKey" class="btn btn-success mt-2">Use your security key</button>
</div>
{% if enable_otp %}
<hr>
@ -68,7 +70,6 @@
$('#formRegisterKey').submit();
}
$("#btnVerifyKey").click(verifyKey);
</script>
{% if auto_activate %}

View File

@ -12,7 +12,7 @@ from app.config import MFA_USER_ID
from app.config import RP_ID, URL
from app.extensions import db
from app.log import LOG
from app.models import User
from app.models import User, Fido
class FidoTokenForm(FlaskForm):
@ -40,17 +40,6 @@ def fido():
next_url = request.args.get("next")
webauthn_user = webauthn.WebAuthnUser(
user.fido_uuid,
user.email,
user.name if user.name else user.email,
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:
@ -61,11 +50,23 @@ def fido():
challenge = session["fido_challenge"]
webauthn_assertion_response = webauthn.WebAuthnAssertionResponse(
webauthn_user, sk_assertion, challenge, URL, uv_required=False
)
try:
fido_key = Fido.get_by(
uuid=user.fido_uuid, credential_id=sk_assertion["id"]
)
webauthn_user = webauthn.WebAuthnUser(
user.fido_uuid,
user.email,
user.name if user.name else user.email,
False,
fido_key.credential_id,
fido_key.public_key,
fido_key.sign_count,
RP_ID,
)
webauthn_assertion_response = webauthn.WebAuthnAssertionResponse(
webauthn_user, sk_assertion, challenge, URL, uv_required=False
)
new_sign_count = webauthn_assertion_response.verify()
except Exception as e:
LOG.error(f"An error occurred in WebAuthn verification process: {e}")
@ -93,8 +94,24 @@ def fido():
session["fido_challenge"] = challenge.rstrip("=")
fidos = Fido.filter_by(uuid=user.fido_uuid).all()
webauthn_users = []
for fido in fidos:
webauthn_users.append(
webauthn.WebAuthnUser(
user.fido_uuid,
user.email,
user.name if user.name else user.email,
False,
fido.credential_id,
fido.public_key,
fido.sign_count,
RP_ID,
)
)
webauthn_assertion_options = webauthn.WebAuthnAssertionOptions(
webauthn_user, challenge
webauthn_users, challenge
)
webauthn_assertion_options = webauthn_assertion_options.assertion_dict

View File

@ -9,10 +9,11 @@ from .views import (
api_key,
custom_domain,
alias_contact_manager,
enter_sudo,
mfa_setup,
mfa_cancel,
fido_setup,
fido_cancel,
fido_manage,
domain_detail,
lifetime_licence,
directory,

View File

@ -1,14 +1,17 @@
{% extends 'default.html' %}
{% set active_page = "setting" %}
{% block title %}
Unlink Security Key
SUDO MODE
{% endblock %}
{% block default_content %}
<div class="card">
<div class="card-body">
<h1 class="h2">Unlink Your Security Key</h1>
<h1 class="h2">Entering Sudo Mode</h1>
<p>
You are trying to change sensitive settings
</p>
<p>
Please enter the password of your account so that we can ensure it's you.
</p>
@ -20,7 +23,7 @@
{{ 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>
<button class="btn btn-lg btn-danger mt-2">Submit</button>
</form>
</div>

View File

@ -0,0 +1,53 @@
{% extends 'default.html' %}
{% set active_page = "setting" %}
{% block title %}
Manage Security Key
{% endblock %}
{% block head %}
<script src="{{ url_for('static', filename='node_modules/qrious/dist/qrious.min.js') }}"></script>
{% endblock %}
{% block default_content %}
<div class="card">
<div class="card-body">
<h1 class="h2">Manage Your Security Key</h1>
<p>Unlink all keys will also disable WebAuthn 2FA.</p>
<form id="formManageKey" method="post">
{{ fido_manage_form.csrf_token }}
{{ fido_manage_form.credential_id(class="form-control", placeholder="") }}
</form>
<table class="table table-striped">
<thead>
<tr>
<th scope="col">ID</th>
<th scope="col">Name</th>
<th scope="col">Linked At</th>
<th scope="col" class="text-center">Operation</th>
</tr>
</thead>
<tbody>
{%for key in keys%}
<tr>
<th scope="row">{{ key.id }}</th>
<td>{{ key.name }}</td>
<td>{{key.created_at | dt}}</td>
<td class="text-center"><button class="btn btn-outline-danger" onclick="$('#credential_id').val('{{ key.credential_id }}'); $('#formManageKey').submit();">Unlink</button></td>
</tr>
{%endfor%}
<tr>
<th scope="row">#</th>
<td>Link a New Key</td>
<td></td>
<td class="text-center"><a href="{{ url_for('dashboard.fido_setup') }}"><button class="btn btn-outline-success">Link</button></a></td>
</tr>
</tbody>
</table>
</div>
</div>
{% endblock %}

View File

@ -19,10 +19,14 @@
<form id="formRegisterKey" method="post">
{{ fido_token_form.csrf_token }}
{{ fido_token_form.sk_assertion(class="form-control", placeholder="") }}
{{ fido_token_form.key_name(class="form-control", placeholder="Name of your key (Required)") }}
{{ render_field_errors(fido_token_form.key_name) }}
<div class="text-center">
<button id="btnRegisterKey" class="btn btn-lg btn-primary mt-2" onclick="registerKey();">Register Key</button>
</div>
</form>
<div class="text-center">
<button id="btnRegisterKey" class="btn btn-lg btn-primary mt-2">Register Key</button>
</div>
<script>
async function registerKey() {
@ -33,6 +37,7 @@
JSON.parse('{{credential_create_options|tojson|safe}}')
)
let credential
try {
credential = await navigator.credentials.create({
@ -51,8 +56,6 @@
$('#formRegisterKey').submit();
}
$("#btnRegisterKey").click(registerKey);
$('document').ready(registerKey());
</script>
</div>

View File

@ -97,7 +97,7 @@
{% 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>
<a href="{{ url_for('dashboard.fido_manage') }}" class="btn btn-outline-info">Manage WebAuthn</a>
<a href="{{ url_for('dashboard.recovery_code_route') }}" class="btn btn-outline-secondary">Recovery Codes</a>
{% endif %}
</div>

View File

@ -0,0 +1,56 @@
from time import time
from flask import render_template, flash, redirect, url_for, session, request
from flask_login import login_required, current_user
from flask_wtf import FlaskForm
from wtforms import PasswordField, validators
from functools import wraps
from app.dashboard.base import dashboard_bp
from app.log import LOG
_SUDO_GAP = 900
class LoginForm(FlaskForm):
password = PasswordField("Password", validators=[validators.DataRequired()])
@dashboard_bp.route("/enter_sudo", methods=["GET", "POST"])
@login_required
def enter_sudo():
password_check_form = LoginForm()
if password_check_form.validate_on_submit():
password = password_check_form.password.data
if current_user.check_password(password):
session["sudo_time"] = int(time())
# User comes to sudo page from another page
next_url = request.args.get("next")
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"))
else:
flash("Incorrect password", "warning")
return render_template(
"dashboard/enter_sudo.html", password_check_form=password_check_form
)
def sudo_required(f):
@wraps(f)
def wrap(*args, **kwargs):
if (
"sudo_time" not in session
or (time() - int(session["sudo_time"])) > _SUDO_GAP
):
return redirect(url_for("dashboard.enter_sudo", next=request.path))
return f(*args, **kwargs)
return wrap

View File

@ -1,45 +0,0 @@
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
from app.models import RecoveryCode
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()
# user does not have any 2FA enabled left, delete all recovery codes
if not current_user.two_factor_authentication_enabled():
RecoveryCode.empty(current_user)
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
)

View File

@ -0,0 +1,59 @@
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 HiddenField, validators
from app.dashboard.base import dashboard_bp
from app.extensions import db
from app.log import LOG
from app.models import RecoveryCode, Fido
from app.dashboard.views.enter_sudo import sudo_required
class FidoManageForm(FlaskForm):
credential_id = HiddenField("credential_id", validators=[validators.DataRequired()])
@dashboard_bp.route("/fido_manage", methods=["GET", "POST"])
@login_required
@sudo_required
def fido_manage():
if not current_user.fido_enabled():
flash("You haven't registered a security key", "warning")
return redirect(url_for("dashboard.index"))
fido_manage_form = FidoManageForm()
if fido_manage_form.validate_on_submit():
credential_id = fido_manage_form.credential_id.data
fido_key = Fido.get_by(uuid=current_user.fido_uuid, credential_id=credential_id)
if not fido_key:
flash("Unknown error, redirect back to manage page", "warning")
return redirect(url_for("dashboard.fido_manage"))
Fido.delete(fido_key.id)
db.session.commit()
LOG.d(f"FIDO Key ID={fido_key.id} Removed")
flash(f"Key {fido_key.name} successfully unlinked", "success")
# Disable FIDO for the user if all keys have been deleted
if not Fido.filter_by(uuid=current_user.fido_uuid).all():
current_user.fido_uuid = None
db.session.commit()
# user does not have any 2FA enabled left, delete all recovery codes
if not current_user.two_factor_authentication_enabled():
RecoveryCode.empty(current_user)
return redirect(url_for("dashboard.index"))
return redirect(url_for("dashboard.fido_manage"))
return render_template(
"dashboard/fido_manage.html",
fido_manage_form=fido_manage_form,
keys=Fido.filter_by(uuid=current_user.fido_uuid),
)

View File

@ -1,30 +1,31 @@
import json
import secrets
import uuid
from time import time
import webauthn
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 wtforms import StringField, HiddenField, validators
from app.config import RP_ID, URL
from app.dashboard.base import dashboard_bp
from app.extensions import db
from app.log import LOG
from app.models import Fido, RecoveryCode
from app.dashboard.views.enter_sudo import sudo_required
class FidoTokenForm(FlaskForm):
key_name = StringField("key_name", validators=[validators.DataRequired()])
sk_assertion = HiddenField("sk_assertion", validators=[validators.DataRequired()])
@dashboard_bp.route("/fido_setup", methods=["GET", "POST"])
@login_required
@sudo_required
def fido_setup():
if current_user.fido_enabled():
flash("You have already registered your security key", "warning")
return redirect(url_for("dashboard.index"))
if not current_user.can_use_fido:
flash(
"This feature is currently in invitation-only beta. Please send us an email if you want to try",
@ -32,6 +33,11 @@ def fido_setup():
)
return redirect(url_for("dashboard.index"))
if current_user.fido_uuid is not None:
fidos = Fido.filter_by(uuid=current_user.fido_uuid).all()
else:
fidos = []
fido_token_form = FidoTokenForm()
# Handling POST requests
@ -61,17 +67,32 @@ def fido_setup():
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")
if current_user.fido_uuid is None:
current_user.fido_uuid = fido_uuid
Fido.create(
credential_id=str(fido_credential.credential_id, "utf-8"),
uuid=fido_uuid,
public_key=str(fido_credential.public_key, "utf-8"),
sign_count=fido_credential.sign_count,
name=fido_token_form.key_name.data,
)
db.session.commit()
LOG.d(
f"credential_id={str(fido_credential.credential_id, 'utf-8')} added for {fido_uuid}"
)
flash("Security key has been activated", "success")
return redirect(url_for("dashboard.recovery_code_route"))
if not RecoveryCode.query.filter_by(user_id=current_user.id).all():
return redirect(url_for("dashboard.recovery_code_route"))
else:
return redirect(url_for("dashboard.fido_manage"))
# Prepare information for key registration process
fido_uuid = str(uuid.uuid4())
fido_uuid = (
str(uuid.uuid4()) if current_user.fido_uuid is None else current_user.fido_uuid
)
challenge = secrets.token_urlsafe(32)
credential_create_options = webauthn.WebAuthnMakeCredentialOptions(
@ -91,6 +112,16 @@ def fido_setup():
registration_dict = credential_create_options.registration_dict
del registration_dict["extensions"]["webauthn.loc"]
# Prevent user from adding duplicated keys
for fido in fidos:
registration_dict["excludeCredentials"].append(
{
"type": "public-key",
"id": fido.credential_id,
"transports": ["usb", "nfc", "ble", "internal"],
}
)
session["fido_uuid"] = fido_uuid
session["fido_challenge"] = challenge.rstrip("=")

View File

@ -7,6 +7,7 @@ from wtforms import StringField, validators
from app.dashboard.base import dashboard_bp
from app.extensions import db
from app.models import RecoveryCode
from app.dashboard.views.enter_sudo import sudo_required
class OtpTokenForm(FlaskForm):
@ -15,6 +16,7 @@ class OtpTokenForm(FlaskForm):
@dashboard_bp.route("/mfa_cancel", methods=["GET", "POST"])
@login_required
@sudo_required
def mfa_cancel():
if not current_user.enable_otp:
flash("you don't have MFA enabled", "warning")

View File

@ -7,6 +7,7 @@ from wtforms import StringField, validators
from app.dashboard.base import dashboard_bp
from app.extensions import db
from app.log import LOG
from app.dashboard.views.enter_sudo import sudo_required
class OtpTokenForm(FlaskForm):
@ -15,6 +16,7 @@ class OtpTokenForm(FlaskForm):
@dashboard_bp.route("/mfa_setup", methods=["GET", "POST"])
@login_required
@sudo_required
def mfa_setup():
if current_user.enable_otp:
flash("you have already enabled MFA", "warning")

View File

@ -121,6 +121,19 @@ class AliasGeneratorEnum(EnumE):
uuid = 2 # aliases are generated based on uuid
class Fido(db.Model, ModelMixin):
__tablename__ = "fido"
credential_id = db.Column(db.String(), nullable=False, unique=True, index=True)
uuid = db.Column(
db.ForeignKey("users.fido_uuid", ondelete="cascade"),
unique=False,
nullable=False,
)
public_key = db.Column(db.String(), nullable=False, unique=True)
sign_count = db.Column(db.Integer(), nullable=False)
name = db.Column(db.String(128), nullable=False, unique=False)
class User(db.Model, ModelMixin, UserMixin):
__tablename__ = "users"
email = db.Column(db.String(256), unique=True, nullable=False)
@ -151,9 +164,6 @@ class User(db.Model, ModelMixin, UserMixin):
# 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)
# whether user can use Fido
can_use_fido = db.Column(

View File

@ -38,6 +38,7 @@ from app.log import LOG
from app.models import (
Client,
User,
Fido,
ClientUser,
Alias,
RedirectUri,
@ -143,8 +144,10 @@ def fake_data():
otp_secret="base32secret3232",
can_use_fido=True,
intro_shown=True,
fido_uuid=None,
)
db.session.commit()
user.trial_end = None
LifetimeCoupon.create(code="coupon", nb_used=10)

View File

@ -56,7 +56,7 @@ const transformCredentialRequestOptions = (
const transformCredentialCreateOptions = (
credentialCreateOptionsFromServer
) => {
let { challenge, user } = credentialCreateOptionsFromServer;
let { challenge, user, excludeCredentials } = credentialCreateOptionsFromServer;
user.id = Uint8Array.from(
atob(
credentialCreateOptionsFromServer.user.id
@ -75,10 +75,17 @@ const transformCredentialCreateOptions = (
(c) => c.charCodeAt(0)
);
excludeCredentials = excludeCredentials.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 transformedCredentialCreateOptions = Object.assign(
{},
credentialCreateOptionsFromServer,
{ challenge, user }
{ challenge, user, excludeCredentials }
);
return transformedCredentialCreateOptions;