diff --git a/uffd/mfa/templates/auth.html b/uffd/mfa/templates/auth.html index 8e3599950eb2706bff5e4e7f4bd2739475680143..57f5d588454bfbe7237c99cdcdc7a148576d3983 100644 --- a/uffd/mfa/templates/auth.html +++ b/uffd/mfa/templates/auth.html @@ -57,7 +57,7 @@ function begin_webauthn() { return navigator.credentials.get(options); }).then(function(assertion) { $('#webauthn-btn-text').text('Verifing response'); - return fetch({{ url_for('mfa.auth_webauthn_begin')|tojson }}, { + return fetch({{ url_for('mfa.auth_webauthn_complete')|tojson }}, { method: 'POST', headers: {'Content-Type': 'application/cbor'}, body: CBOR.encode({ diff --git a/uffd/mfa/templates/setup.html b/uffd/mfa/templates/setup.html index 80cb5d58217f5f17b6bfda5ea33121903d764520..48388149007311f58184c627b243fb34a9929586 100644 --- a/uffd/mfa/templates/setup.html +++ b/uffd/mfa/templates/setup.html @@ -27,7 +27,7 @@ You need to setup at least one authentication method to enable two-factor authen <button type="submit" class="btn btn-danger mb-2">Disable two-factor authentication</button> </form> {% else %} - <form class="form float-right" action="{{ url_for('mfa.disable') }}" method="POST"> + <form class="form float-right" action="{{ url_for('mfa.disable_confirm') }}" method="POST"> <button type="submit" class="btn btn-light mb-2">Reset two-factor configuration</button> </form> {% endif %} diff --git a/uffd/mfa/templates/setup_totp.html b/uffd/mfa/templates/setup_totp.html index fef0c4391c65030ab1328c97a45f164a09de21b9..f0850914373e8e1dfe458a97c2fba9663e8d66e1 100644 --- a/uffd/mfa/templates/setup_totp.html +++ b/uffd/mfa/templates/setup_totp.html @@ -25,7 +25,7 @@ </div> </div> -<form action="{{ url_for('mfa.setup_totp', name=name) }}" method="POST" class="form"> +<form action="{{ url_for('mfa.setup_totp_finish', name=name) }}" method="POST" class="form"> <div class="row m-0"> <input type="text" name="code" class="form-control mb-2 mr-2 col-auto col-md" id="code" placeholder="Code" required autofocus> <button type="submit" class="btn btn-primary mb-2 col col-md-auto">Verify and complete setup</button> diff --git a/uffd/mfa/views.py b/uffd/mfa/views.py index 170653bb770b75fb9876c3ca5ed99992ea859b53..2237db2d6f3ab03ca504f34797cd81c8f68354f7 100644 --- a/uffd/mfa/views.py +++ b/uffd/mfa/views.py @@ -1,4 +1,4 @@ -from flask import Blueprint, render_template, session, request, redirect, url_for, flash +from flask import Blueprint, render_template, session, request, redirect, url_for, flash, current_app import urllib.parse from fido2.webauthn import PublicKeyCredentialRpEntity, UserVerificationRequirement @@ -9,7 +9,10 @@ from fido2 import cbor from uffd.database import db from uffd.mfa.models import MFAMethod, TOTPMethod, WebauthnMethod, RecoveryCodeMethod -from uffd.session.views import get_current_user, login_required +from uffd.session.views import get_current_user, login_required, is_valid_session +from uffd.ldap import uid_to_dn +from uffd.user.models import User +from uffd.csrf import csrf_protect bp = Blueprint('mfa', __name__, template_folder='templates', url_prefix='/mfa/') @@ -29,14 +32,31 @@ def disable(): @bp.route('/setup/disable', methods=['POST']) @login_required() +@csrf_protect(blueprint=bp) def disable_confirm(): user = get_current_user() MFAMethod.query.filter_by(dn=user.dn).delete() db.session.commit() return redirect(url_for('mfa.setup')) +@bp.route('/admin/<int:uid>/disable') +@login_required() +@csrf_protect(blueprint=bp) +def admin_disable(uid): + # Group cannot be checked with login_required kwarg, because the config + # variable is not available when the decorator is processed + if not get_current_user().is_in_group(current_app.config['ACL_ADMIN_GROUP']): + flash('Access denied') + return redirect(url_for('index')) + user = User.from_ldap_dn(uid_to_dn(uid)) + MFAMethod.query.filter_by(dn=user.dn).delete() + db.session.commit() + flash('Two-factor authentication was reset') + return redirect(url_for('user.show', uid=uid)) + @bp.route('/setup/recovery', methods=['POST']) @login_required() +@csrf_protect(blueprint=bp) def setup_recovery(): user = get_current_user() for method in RecoveryCodeMethod.query.filter_by(dn=user.dn).all(): @@ -59,6 +79,7 @@ def setup_totp(): @bp.route('/setup/totp', methods=['POST']) @login_required() +@csrf_protect(blueprint=bp) def setup_totp_finish(): user = get_current_user() if not RecoveryCodeMethod.query.filter_by(dn=user.dn).all(): @@ -74,6 +95,7 @@ def setup_totp_finish(): @bp.route('/setup/totp/<int:id>/delete') @login_required() +@csrf_protect(blueprint=bp) def delete_totp(id): user = get_current_user() method = TOTPMethod.query.filter_by(dn=user.dn, id=id).first_or_404() @@ -86,6 +108,7 @@ def get_webauthn_server(): @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(): @@ -108,6 +131,7 @@ def setup_webauthn_begin(): @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() @@ -123,6 +147,7 @@ def setup_webauthn_complete(): @bp.route('/setup/webauthn/<int:id>/delete') @login_required() +@csrf_protect(blueprint=bp) def delete_webauthn(id): user = get_current_user() method = WebauthnMethod.query.filter_by(dn=user.dn, id=id).first_or_404() @@ -136,7 +161,6 @@ def auth_webauthn_begin(): server = get_webauthn_server() methods = WebauthnMethod.query.filter_by(dn=user.dn).all() creds = [method.cred_data.credential_data for method in methods] - print(creds) if not creds: abort(404) auth_data, state = server.authenticate_begin(creds, user_verification=UserVerificationRequirement.DISCOURAGED) @@ -156,44 +180,46 @@ def auth_webauthn_complete(): client_data = ClientData(data["clientDataJSON"]) auth_data = AuthenticatorData(data["authenticatorData"]) signature = data["signature"] - print("clientData", client_data) - print("AuthenticatorData", auth_data) server.authenticate_complete( - session.pop("state"), + session.pop("webauthn-state"), creds, credential_id, client_data, auth_data, signature, ) - print("ASSERTION OK") + session['user_mfa'] = True return cbor.encode({"status": "OK"}) @bp.route('/auth', methods=['GET']) -@login_required() +@login_required(skip_mfa=True) def auth(): user = get_current_user() recovery_methods = RecoveryCodeMethod.query.filter_by(dn=user.dn).all() totp_methods = TOTPMethod.query.filter_by(dn=user.dn).all() webauthn_methods = WebauthnMethod.query.filter_by(dn=user.dn).all() + if not totp_methods and not webauthn_methods: + session['user_mfa'] = True + if session.get('user_mfa'): + return redirect(request.values.get('ref', url_for('index'))) return render_template('auth.html', ref=request.values.get('ref'), totp_methods=totp_methods, webauthn_methods=webauthn_methods, recovery_methods=recovery_methods) @bp.route('/auth', methods=['POST']) -@login_required() +@login_required(skip_mfa=True) def auth_finish(): user = get_current_user() recovery_methods = RecoveryCodeMethod.query.filter_by(dn=user.dn).all() totp_methods = TOTPMethod.query.filter_by(dn=user.dn).all() for method in totp_methods: if method.verify(request.form['code']): - session['mfa_verifed'] = True + session['user_mfa'] = True return redirect(request.values.get('ref', url_for('index'))) for method in recovery_methods: if method.verify(request.form['code']): db.session.delete(method) db.session.commit() - session['mfa_verifed'] = True + session['user_mfa'] = True if len(recovery_methods) <= 1: flash('You have exhausted your recovery codes. Please generate new ones now!') return redirect(url_for('mfa.setup')) diff --git a/uffd/selfservice/templates/self.html b/uffd/selfservice/templates/self.html index 874fac7d0c6f0883b34b59285df68cb049ebc871..026aaf61fec084f5dcf1e09c99069a60f63bbcc9 100644 --- a/uffd/selfservice/templates/self.html +++ b/uffd/selfservice/templates/self.html @@ -1,6 +1,11 @@ {% extends 'base.html' %} {% block body %} + +<div class="btn-toolbar"> + <a class="ml-auto mb-3 btn btn-primary" href="{{ url_for('mfa.setup') }}">Manage two-factor authentication</a> +</div> + <form action="{{ url_for("selfservice.update") }}" method="POST" onInput=" password2.setCustomValidity(password1.value != password2.value ? 'Passwords do not match.' : ''); password1.setCustomValidity((password1.value.length < 8 || password1.value.length == 0) ? 'Password is too short' : '') "> diff --git a/uffd/session/views.py b/uffd/session/views.py index 5cb275bbc412e93192e2e3477f4c2cdda5e591d0..561d190956579068833aab8028672e31ce98c227 100644 --- a/uffd/session/views.py +++ b/uffd/session/views.py @@ -22,11 +22,9 @@ def login(): username = request.form['loginname'] password = request.form['password'] conn = user_conn(username, password) - if not conn: - flash('Login name or password is wrong') - return redirect(url_for('.login')) - conn.search(conn.user, '(objectClass=person)') - if not len(conn.entries) == 1: + if conn: + conn.search(conn.user, '(objectClass=person)') + if not conn or len(conn.entries) != 1: flash('Login name or password is wrong') return render_template('login.html', ref=request.values.get('ref')) user = User.from_ldap(conn.entries[0]) @@ -36,7 +34,7 @@ def login(): session['user_uid'] = user.uid session['logintime'] = datetime.datetime.now().timestamp() session['_csrf_token'] = secrets.token_hex(128) - return redirect(request.values.get('ref', url_for('index'))) + return redirect(url_for('mfa.auth', ref=request.values.get('ref', url_for('index')))) def get_current_user(): if not session.get('user_uid'): @@ -45,22 +43,32 @@ def get_current_user(): request.current_user = User.from_ldap_dn(uid_to_dn(session['user_uid'])) return request.current_user -def is_valid_session(): +def login_valid(): user = get_current_user() if not user: return False if datetime.datetime.now().timestamp() > session['logintime'] + current_app.config['SESSION_LIFETIME_SECONDS']: return False return True + +def is_valid_session(): + if not login_valid(): + return False + if not session.get('user_mfa'): + return False + return True bp.add_app_template_global(is_valid_session) -def login_required(group=None): +def login_required(group=None, skip_mfa=False): def wrapper(func): @functools.wraps(func) def decorator(*args, **kwargs): - if not is_valid_session(): + if not login_valid(): flash('You need to login first') return redirect(url_for('session.login', ref=request.url)) + if not skip_mfa and not session.get('user_mfa'): + print('redirecting login_required', skip_mfa, session.get('user_mfa')) + return redirect(url_for('mfa.auth', ref=request.url)) if not get_current_user().is_in_group(group): flash('Access denied') return redirect(url_for('index')) diff --git a/uffd/user/templates/user.html b/uffd/user/templates/user.html index 68dcb7eaac2201bf74d5078991c97f2de8e8eb1b..bb9c1e6a95ca4ab2270b58575f4cb9c7b4793e98 100644 --- a/uffd/user/templates/user.html +++ b/uffd/user/templates/user.html @@ -6,6 +6,7 @@ <div class="float-sm-right pb-2"> <button type="submit" class="btn btn-primary"><i class="fa fa-save" aria-hidden="true"></i> Save</button> <a href="{{ url_for("user.index") }}" class="btn btn-secondary">Cancel</a> + <a href="{{ url_for("mfa.admin_disable", uid=user.uid) }}" class="btn btn-secondary">Reset 2FA</a> {% if user.uid %} <a href="{{ url_for("user.delete", uid=user.uid) }}" class="btn btn-danger"><i class="fa fa-trash" aria-hidden="true"></i> Delete</a> {% else %}