diff --git a/app/auth/templates/auth/fido.html b/app/auth/templates/auth/fido.html index 35f8b257..b2d2ea75 100644 --- a/app/auth/templates/auth/fido.html +++ b/app/auth/templates/auth/fido.html @@ -22,10 +22,12 @@
{{ fido_token_form.csrf_token }} {{ fido_token_form.sk_assertion(class="form-control", placeholder="") }} + +
+ +
-
- -
+ {% if enable_otp %}
@@ -68,7 +70,6 @@ $('#formRegisterKey').submit(); } - $("#btnVerifyKey").click(verifyKey); {% if auto_activate %} diff --git a/app/auth/views/fido.py b/app/auth/views/fido.py index 472747ac..71c1f429 100644 --- a/app/auth/views/fido.py +++ b/app/auth/views/fido.py @@ -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 diff --git a/app/dashboard/__init__.py b/app/dashboard/__init__.py index 7a6fcabd..fc216406 100644 --- a/app/dashboard/__init__.py +++ b/app/dashboard/__init__.py @@ -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, diff --git a/app/dashboard/templates/dashboard/fido_cancel.html b/app/dashboard/templates/dashboard/enter_sudo.html similarity index 73% rename from app/dashboard/templates/dashboard/fido_cancel.html rename to app/dashboard/templates/dashboard/enter_sudo.html index 0df4c53d..934f7aa3 100644 --- a/app/dashboard/templates/dashboard/fido_cancel.html +++ b/app/dashboard/templates/dashboard/enter_sudo.html @@ -1,14 +1,17 @@ {% extends 'default.html' %} {% set active_page = "setting" %} {% block title %} - Unlink Security Key + SUDO MODE {% endblock %} {% block default_content %}
-

Unlink Your Security Key

+

Entering Sudo Mode

+

+ You are trying to change sensitive settings +

Please enter the password of your account so that we can ensure it's you.

@@ -20,7 +23,7 @@ {{ password_check_form.password(class="form-control", autofocus="true") }} {{ render_field_errors(password_check_form.password) }} - +
diff --git a/app/dashboard/templates/dashboard/fido_manage.html b/app/dashboard/templates/dashboard/fido_manage.html new file mode 100644 index 00000000..996a2760 --- /dev/null +++ b/app/dashboard/templates/dashboard/fido_manage.html @@ -0,0 +1,53 @@ +{% extends 'default.html' %} +{% set active_page = "setting" %} +{% block title %} + Manage Security Key +{% endblock %} + +{% block head %} + +{% endblock %} + +{% block default_content %} +
+
+

Manage Your Security Key

+

Unlink all keys will also disable WebAuthn 2FA.

+ +
+ {{ fido_manage_form.csrf_token }} + {{ fido_manage_form.credential_id(class="form-control", placeholder="") }} +
+ + + + + + + + + + + + {%for key in keys%} + + + + + + + {%endfor%} + + + + + + + +
IDNameLinked AtOperation
{{ key.id }}{{ key.name }}{{key.created_at | dt}}
#Link a New Key
+ + + +
+
+{% endblock %} diff --git a/app/dashboard/templates/dashboard/fido_setup.html b/app/dashboard/templates/dashboard/fido_setup.html index f82b8895..3a4c16aa 100644 --- a/app/dashboard/templates/dashboard/fido_setup.html +++ b/app/dashboard/templates/dashboard/fido_setup.html @@ -19,10 +19,14 @@
{{ 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) }} + +
+ +
-
- -
diff --git a/app/dashboard/templates/dashboard/setting.html b/app/dashboard/templates/dashboard/setting.html index d7810d6e..b72915df 100644 --- a/app/dashboard/templates/dashboard/setting.html +++ b/app/dashboard/templates/dashboard/setting.html @@ -97,7 +97,7 @@ {% if current_user.fido_uuid is none %} Setup WebAuthn {% else %} - Disable WebAuthn + Manage WebAuthn Recovery Codes {% endif %} diff --git a/app/dashboard/views/enter_sudo.py b/app/dashboard/views/enter_sudo.py new file mode 100644 index 00000000..c2c4819e --- /dev/null +++ b/app/dashboard/views/enter_sudo.py @@ -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 diff --git a/app/dashboard/views/fido_cancel.py b/app/dashboard/views/fido_cancel.py deleted file mode 100644 index 621aa6f5..00000000 --- a/app/dashboard/views/fido_cancel.py +++ /dev/null @@ -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 - ) diff --git a/app/dashboard/views/fido_manage.py b/app/dashboard/views/fido_manage.py new file mode 100644 index 00000000..db684868 --- /dev/null +++ b/app/dashboard/views/fido_manage.py @@ -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), + ) diff --git a/app/dashboard/views/fido_setup.py b/app/dashboard/views/fido_setup.py index bf1b00fd..5f7283b2 100644 --- a/app/dashboard/views/fido_setup.py +++ b/app/dashboard/views/fido_setup.py @@ -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("=") diff --git a/app/dashboard/views/mfa_cancel.py b/app/dashboard/views/mfa_cancel.py index d982b571..66c36c2a 100644 --- a/app/dashboard/views/mfa_cancel.py +++ b/app/dashboard/views/mfa_cancel.py @@ -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") diff --git a/app/dashboard/views/mfa_setup.py b/app/dashboard/views/mfa_setup.py index 255e58a1..2b7ac48c 100644 --- a/app/dashboard/views/mfa_setup.py +++ b/app/dashboard/views/mfa_setup.py @@ -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") diff --git a/app/models.py b/app/models.py index eaba9ded..300776ad 100644 --- a/app/models.py +++ b/app/models.py @@ -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( diff --git a/server.py b/server.py index 562d391c..f2269a7f 100644 --- a/server.py +++ b/server.py @@ -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) diff --git a/static/assets/js/vendors/webauthn.js b/static/assets/js/vendors/webauthn.js index 85a8ac22..5604a41f 100644 --- a/static/assets/js/vendors/webauthn.js +++ b/static/assets/js/vendors/webauthn.js @@ -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;