diff --git a/README.md b/README.md index 810a27b72a7ee9c5975cf87f9813aff322419d6d..18ee6ba02ae88a6c4492e3ae0be81b7264f54905 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ A web service to manage LDAP users, groups and permissions. - python3-flask - python3-flask-sqlalchemy - python3-qrcode -- python3-fido2 +- python3-fido2 (version 0.5.0, optional) - git (cli utility, musst be in path) ## development diff --git a/uffd/mfa/models.py b/uffd/mfa/models.py index 176c96f134989cea3bfd3619255746e3b4dfeaa2..d27b5f810966f7e746e619fdb95a3621d9792b18 100644 --- a/uffd/mfa/models.py +++ b/uffd/mfa/models.py @@ -6,8 +6,6 @@ import crypt from flask import request, current_app from sqlalchemy import Column, Integer, Enum, Boolean, String, DateTime, Text -from fido2.ctap2 import AttestedCredentialData - from uffd.database import db from uffd.user.models import User @@ -137,6 +135,7 @@ class WebauthnMethod(MFAMethod): @property def cred(self): + from fido2.ctap2 import AttestedCredentialData return AttestedCredentialData(base64.b64decode(self._cred)) @cred.setter diff --git a/uffd/mfa/templates/auth.html b/uffd/mfa/templates/auth.html index 2bacaf22618acabec1fde62e1a1df09bc3e9d660..945c4357233cf1c261c1b85e900535874c78e78c 100644 --- a/uffd/mfa/templates/auth.html +++ b/uffd/mfa/templates/auth.html @@ -39,7 +39,7 @@ </div> </form> -{% if webauthn_methods %} +{% if webauthn_supported and webauthn_methods %} <script src="{{ url_for('static', filename="cbor.js") }}"></script> <script> function begin_webauthn() { diff --git a/uffd/mfa/templates/setup.html b/uffd/mfa/templates/setup.html index 681aa25780057e167eac4875081953dc8a6e12ce..93b634b2fa02e5867666e8c3d61a8e12693def63 100644 --- a/uffd/mfa/templates/setup.html +++ b/uffd/mfa/templates/setup.html @@ -69,7 +69,7 @@ You need to setup at least one authentication method to enable two-factor authen <div class="row mt-3"> <div class="col-12 col-md-5"> - <h4>Authenticator Apps</h4> + <h4>Authenticator Apps (TOTP)</h4> <p>Use an authenticator application on your mobile device as a second factor.</p> <p>The authenticator app generates a 6-digit one-time code each time you login. Compatible apps are freely available for most phones.</p> @@ -121,6 +121,9 @@ You need to setup at least one authentication method to enable two-factor authen </div> <div class="col-12 col-md-7"> + {% if not webauthn_supported %} + <div class="alert alert-warning" role="alert">U2F/FIDO2 support not enabled</div> + {% endif %} <noscript> <div class="alert alert-warning" role="alert">Enable javascript in your browser to use U2F and FIDO2 devices!</div> </noscript> @@ -162,6 +165,7 @@ You need to setup at least one authentication method to enable two-factor authen </div> </div> +{% if webauthn_supported %} <script src="{{ url_for('static', filename="cbor.js") }}"></script> <script> @@ -238,5 +242,6 @@ if (typeof(PublicKeyCredential) != "undefined") { } </script> +{% endif %} {% endblock %} diff --git a/uffd/mfa/views.py b/uffd/mfa/views.py index fbaad53b1808f09bdb5525ef593c50285168853b..447623aac660ad617ee4d34177878874b00f3493 100644 --- a/uffd/mfa/views.py +++ b/uffd/mfa/views.py @@ -1,10 +1,6 @@ from flask import Blueprint, render_template, session, request, redirect, url_for, flash, current_app, abort import urllib.parse - -from fido2.client import ClientData -from fido2.server import Fido2Server, RelyingParty -from fido2.ctap2 import AttestationObject, AuthenticatorData -from fido2 import cbor +from warnings import warn from uffd.database import db from uffd.mfa.models import MFAMethod, TOTPMethod, WebauthnMethod, RecoveryCodeMethod @@ -102,45 +98,99 @@ def delete_totp(id): db.session.commit() return redirect(url_for('mfa.setup')) -def get_webauthn_server(): - return Fido2Server(RelyingParty(current_app.config.get('MFA_RP_ID', urllib.parse.urlsplit(request.url).hostname), current_app.config['MFA_RP_NAME'])) - -@bp.route('/setup/webauthn/begin', methods=['POST']) -@login_required() -@csrf_protect(blueprint=bp) -def setup_webauthn_begin(): - user = get_current_user() - if not RecoveryCodeMethod.query.filter_by(dn=user.dn).all(): - abort(403) - methods = WebauthnMethod.query.filter_by(dn=user.dn).all() - creds = [method.cred for method in methods] - server = get_webauthn_server() - registration_data, state = server.register_begin( - { - "id": user.dn.encode(), - "name": user.loginname, - "displayName": user.displayname, - }, - creds, - user_verification='discouraged', - ) - session["webauthn-state"] = state - return cbor.dumps(registration_data) - -@bp.route('/setup/webauthn/complete', methods=['POST']) -@login_required() -@csrf_protect(blueprint=bp) -def setup_webauthn_complete(): - user = get_current_user() - server = get_webauthn_server() - data = cbor.loads(request.get_data())[0] - client_data = ClientData(data["clientDataJSON"]) - att_obj = AttestationObject(data["attestationObject"]) - auth_data = server.register_complete(session["webauthn-state"], client_data, att_obj) - method = WebauthnMethod(user, auth_data.credential_data, name=data['name']) - db.session.add(method) - db.session.commit() - return cbor.dumps({"status": "OK"}) +# WebAuthn support is optional because fido2 has a pretty unstable +# interface (v0.5.0 on buster and current version are completely +# incompatible) and might be difficult to install with the correct version +try: + from fido2.client import ClientData + from fido2.server import Fido2Server, RelyingParty + from fido2.ctap2 import AttestationObject, AuthenticatorData + from fido2 import cbor + webauthn_supported = True +except ImportError as e: + warn('2FA WebAuthn support disabled because import of the fido2 module failed (%s)'%e) + webauthn_supported = False + +bp.add_app_template_global(webauthn_supported, name='webauthn_supported') + +if webauthn_supported: + def get_webauthn_server(): + return Fido2Server(RelyingParty(current_app.config.get('MFA_RP_ID', urllib.parse.urlsplit(request.url).hostname), current_app.config['MFA_RP_NAME'])) + + @bp.route('/setup/webauthn/begin', methods=['POST']) + @login_required() + @csrf_protect(blueprint=bp) + def setup_webauthn_begin(): + user = get_current_user() + if not RecoveryCodeMethod.query.filter_by(dn=user.dn).all(): + abort(403) + methods = WebauthnMethod.query.filter_by(dn=user.dn).all() + creds = [method.cred for method in methods] + server = get_webauthn_server() + registration_data, state = server.register_begin( + { + "id": user.dn.encode(), + "name": user.loginname, + "displayName": user.displayname, + }, + creds, + user_verification='discouraged', + ) + session["webauthn-state"] = state + return cbor.dumps(registration_data) + + @bp.route('/setup/webauthn/complete', methods=['POST']) + @login_required() + @csrf_protect(blueprint=bp) + def setup_webauthn_complete(): + user = get_current_user() + server = get_webauthn_server() + data = cbor.loads(request.get_data())[0] + client_data = ClientData(data["clientDataJSON"]) + att_obj = AttestationObject(data["attestationObject"]) + auth_data = server.register_complete(session["webauthn-state"], client_data, att_obj) + method = WebauthnMethod(user, auth_data.credential_data, name=data['name']) + db.session.add(method) + db.session.commit() + return cbor.dumps({"status": "OK"}) + + @bp.route("/auth/webauthn/begin", methods=["POST"]) + def auth_webauthn_begin(): + user = get_current_user() + server = get_webauthn_server() + methods = WebauthnMethod.query.filter_by(dn=user.dn).all() + creds = [method.cred for method in methods] + if not creds: + abort(404) + auth_data, state = server.authenticate_begin(creds, user_verification='discouraged') + session["webauthn-state"] = state + return cbor.dumps(auth_data) + + @bp.route("/auth/webauthn/complete", methods=["POST"]) + def auth_webauthn_complete(): + user = get_current_user() + server = get_webauthn_server() + methods = WebauthnMethod.query.filter_by(dn=user.dn).all() + creds = [method.cred for method in methods] + if not creds: + abort(404) + data = cbor.loads(request.get_data())[0] + credential_id = data["credentialId"] + client_data = ClientData(data["clientDataJSON"]) + auth_data = AuthenticatorData(data["authenticatorData"]) + signature = data["signature"] + # authenticate_complete() (as of python-fido2 v0.5.0, the version in Debian Buster) + # does not check signCount, although the spec recommends it + server.authenticate_complete( + session.pop("webauthn-state"), + creds, + credential_id, + client_data, + auth_data, + signature, + ) + session['user_mfa'] = True + return cbor.dumps({"status": "OK"}) @bp.route('/setup/webauthn/<int:id>/delete') @login_required() @@ -152,44 +202,6 @@ def delete_webauthn(id): db.session.commit() return redirect(url_for('mfa.setup')) -@bp.route("/auth/webauthn/begin", methods=["POST"]) -def auth_webauthn_begin(): - user = get_current_user() - server = get_webauthn_server() - methods = WebauthnMethod.query.filter_by(dn=user.dn).all() - creds = [method.cred for method in methods] - if not creds: - abort(404) - auth_data, state = server.authenticate_begin(creds, user_verification='discouraged') - session["webauthn-state"] = state - return cbor.dumps(auth_data) - -@bp.route("/auth/webauthn/complete", methods=["POST"]) -def auth_webauthn_complete(): - user = get_current_user() - server = get_webauthn_server() - methods = WebauthnMethod.query.filter_by(dn=user.dn).all() - creds = [method.cred for method in methods] - if not creds: - abort(404) - data = cbor.loads(request.get_data())[0] - credential_id = data["credentialId"] - client_data = ClientData(data["clientDataJSON"]) - auth_data = AuthenticatorData(data["authenticatorData"]) - signature = data["signature"] - # authenticate_complete() (as of python-fido2 v0.5.0, the version in Debian Buster) - # does not check signCount, although the spec recommends it - server.authenticate_complete( - session.pop("webauthn-state"), - creds, - credential_id, - client_data, - auth_data, - signature, - ) - session['user_mfa'] = True - return cbor.dumps({"status": "OK"}) - @bp.route('/auth', methods=['GET']) @login_required(skip_mfa=True) def auth():