Skip to content
Snippets Groups Projects
Commit 0f898174 authored by Julian's avatar Julian
Browse files

mfa integration into selfservice, admin pages and login process

parent 4455a7f2
No related branches found
No related tags found
No related merge requests found
...@@ -57,7 +57,7 @@ function begin_webauthn() { ...@@ -57,7 +57,7 @@ function begin_webauthn() {
return navigator.credentials.get(options); return navigator.credentials.get(options);
}).then(function(assertion) { }).then(function(assertion) {
$('#webauthn-btn-text').text('Verifing response'); $('#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', method: 'POST',
headers: {'Content-Type': 'application/cbor'}, headers: {'Content-Type': 'application/cbor'},
body: CBOR.encode({ body: CBOR.encode({
......
...@@ -27,7 +27,7 @@ You need to setup at least one authentication method to enable two-factor authen ...@@ -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> <button type="submit" class="btn btn-danger mb-2">Disable two-factor authentication</button>
</form> </form>
{% else %} {% 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> <button type="submit" class="btn btn-light mb-2">Reset two-factor configuration</button>
</form> </form>
{% endif %} {% endif %}
......
...@@ -25,7 +25,7 @@ ...@@ -25,7 +25,7 @@
</div> </div>
</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"> <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> <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> <button type="submit" class="btn btn-primary mb-2 col col-md-auto">Verify and complete setup</button>
......
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 import urllib.parse
from fido2.webauthn import PublicKeyCredentialRpEntity, UserVerificationRequirement from fido2.webauthn import PublicKeyCredentialRpEntity, UserVerificationRequirement
...@@ -9,7 +9,10 @@ from fido2 import cbor ...@@ -9,7 +9,10 @@ from fido2 import cbor
from uffd.database import db from uffd.database import db
from uffd.mfa.models import MFAMethod, TOTPMethod, WebauthnMethod, RecoveryCodeMethod 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/') bp = Blueprint('mfa', __name__, template_folder='templates', url_prefix='/mfa/')
...@@ -29,14 +32,31 @@ def disable(): ...@@ -29,14 +32,31 @@ def disable():
@bp.route('/setup/disable', methods=['POST']) @bp.route('/setup/disable', methods=['POST'])
@login_required() @login_required()
@csrf_protect(blueprint=bp)
def disable_confirm(): def disable_confirm():
user = get_current_user() user = get_current_user()
MFAMethod.query.filter_by(dn=user.dn).delete() MFAMethod.query.filter_by(dn=user.dn).delete()
db.session.commit() db.session.commit()
return redirect(url_for('mfa.setup')) 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']) @bp.route('/setup/recovery', methods=['POST'])
@login_required() @login_required()
@csrf_protect(blueprint=bp)
def setup_recovery(): def setup_recovery():
user = get_current_user() user = get_current_user()
for method in RecoveryCodeMethod.query.filter_by(dn=user.dn).all(): for method in RecoveryCodeMethod.query.filter_by(dn=user.dn).all():
...@@ -59,6 +79,7 @@ def setup_totp(): ...@@ -59,6 +79,7 @@ def setup_totp():
@bp.route('/setup/totp', methods=['POST']) @bp.route('/setup/totp', methods=['POST'])
@login_required() @login_required()
@csrf_protect(blueprint=bp)
def setup_totp_finish(): def setup_totp_finish():
user = get_current_user() user = get_current_user()
if not RecoveryCodeMethod.query.filter_by(dn=user.dn).all(): if not RecoveryCodeMethod.query.filter_by(dn=user.dn).all():
...@@ -74,6 +95,7 @@ def setup_totp_finish(): ...@@ -74,6 +95,7 @@ def setup_totp_finish():
@bp.route('/setup/totp/<int:id>/delete') @bp.route('/setup/totp/<int:id>/delete')
@login_required() @login_required()
@csrf_protect(blueprint=bp)
def delete_totp(id): def delete_totp(id):
user = get_current_user() user = get_current_user()
method = TOTPMethod.query.filter_by(dn=user.dn, id=id).first_or_404() method = TOTPMethod.query.filter_by(dn=user.dn, id=id).first_or_404()
...@@ -86,6 +108,7 @@ def get_webauthn_server(): ...@@ -86,6 +108,7 @@ def get_webauthn_server():
@bp.route('/setup/webauthn/begin', methods=['POST']) @bp.route('/setup/webauthn/begin', methods=['POST'])
@login_required() @login_required()
@csrf_protect(blueprint=bp)
def setup_webauthn_begin(): def setup_webauthn_begin():
user = get_current_user() user = get_current_user()
if not RecoveryCodeMethod.query.filter_by(dn=user.dn).all(): if not RecoveryCodeMethod.query.filter_by(dn=user.dn).all():
...@@ -108,6 +131,7 @@ def setup_webauthn_begin(): ...@@ -108,6 +131,7 @@ def setup_webauthn_begin():
@bp.route('/setup/webauthn/complete', methods=['POST']) @bp.route('/setup/webauthn/complete', methods=['POST'])
@login_required() @login_required()
@csrf_protect(blueprint=bp)
def setup_webauthn_complete(): def setup_webauthn_complete():
user = get_current_user() user = get_current_user()
server = get_webauthn_server() server = get_webauthn_server()
...@@ -123,6 +147,7 @@ def setup_webauthn_complete(): ...@@ -123,6 +147,7 @@ def setup_webauthn_complete():
@bp.route('/setup/webauthn/<int:id>/delete') @bp.route('/setup/webauthn/<int:id>/delete')
@login_required() @login_required()
@csrf_protect(blueprint=bp)
def delete_webauthn(id): def delete_webauthn(id):
user = get_current_user() user = get_current_user()
method = WebauthnMethod.query.filter_by(dn=user.dn, id=id).first_or_404() method = WebauthnMethod.query.filter_by(dn=user.dn, id=id).first_or_404()
...@@ -136,7 +161,6 @@ def auth_webauthn_begin(): ...@@ -136,7 +161,6 @@ def auth_webauthn_begin():
server = get_webauthn_server() server = get_webauthn_server()
methods = WebauthnMethod.query.filter_by(dn=user.dn).all() methods = WebauthnMethod.query.filter_by(dn=user.dn).all()
creds = [method.cred_data.credential_data for method in methods] creds = [method.cred_data.credential_data for method in methods]
print(creds)
if not creds: if not creds:
abort(404) abort(404)
auth_data, state = server.authenticate_begin(creds, user_verification=UserVerificationRequirement.DISCOURAGED) auth_data, state = server.authenticate_begin(creds, user_verification=UserVerificationRequirement.DISCOURAGED)
...@@ -156,44 +180,46 @@ def auth_webauthn_complete(): ...@@ -156,44 +180,46 @@ def auth_webauthn_complete():
client_data = ClientData(data["clientDataJSON"]) client_data = ClientData(data["clientDataJSON"])
auth_data = AuthenticatorData(data["authenticatorData"]) auth_data = AuthenticatorData(data["authenticatorData"])
signature = data["signature"] signature = data["signature"]
print("clientData", client_data)
print("AuthenticatorData", auth_data)
server.authenticate_complete( server.authenticate_complete(
session.pop("state"), session.pop("webauthn-state"),
creds, creds,
credential_id, credential_id,
client_data, client_data,
auth_data, auth_data,
signature, signature,
) )
print("ASSERTION OK") session['user_mfa'] = True
return cbor.encode({"status": "OK"}) return cbor.encode({"status": "OK"})
@bp.route('/auth', methods=['GET']) @bp.route('/auth', methods=['GET'])
@login_required() @login_required(skip_mfa=True)
def auth(): def auth():
user = get_current_user() user = get_current_user()
recovery_methods = RecoveryCodeMethod.query.filter_by(dn=user.dn).all() recovery_methods = RecoveryCodeMethod.query.filter_by(dn=user.dn).all()
totp_methods = TOTPMethod.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() 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, return render_template('auth.html', ref=request.values.get('ref'), totp_methods=totp_methods,
webauthn_methods=webauthn_methods, recovery_methods=recovery_methods) webauthn_methods=webauthn_methods, recovery_methods=recovery_methods)
@bp.route('/auth', methods=['POST']) @bp.route('/auth', methods=['POST'])
@login_required() @login_required(skip_mfa=True)
def auth_finish(): def auth_finish():
user = get_current_user() user = get_current_user()
recovery_methods = RecoveryCodeMethod.query.filter_by(dn=user.dn).all() recovery_methods = RecoveryCodeMethod.query.filter_by(dn=user.dn).all()
totp_methods = TOTPMethod.query.filter_by(dn=user.dn).all() totp_methods = TOTPMethod.query.filter_by(dn=user.dn).all()
for method in totp_methods: for method in totp_methods:
if method.verify(request.form['code']): if method.verify(request.form['code']):
session['mfa_verifed'] = True session['user_mfa'] = True
return redirect(request.values.get('ref', url_for('index'))) return redirect(request.values.get('ref', url_for('index')))
for method in recovery_methods: for method in recovery_methods:
if method.verify(request.form['code']): if method.verify(request.form['code']):
db.session.delete(method) db.session.delete(method)
db.session.commit() db.session.commit()
session['mfa_verifed'] = True session['user_mfa'] = True
if len(recovery_methods) <= 1: if len(recovery_methods) <= 1:
flash('You have exhausted your recovery codes. Please generate new ones now!') flash('You have exhausted your recovery codes. Please generate new ones now!')
return redirect(url_for('mfa.setup')) return redirect(url_for('mfa.setup'))
......
{% extends 'base.html' %} {% extends 'base.html' %}
{% block body %} {% 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=" <form action="{{ url_for("selfservice.update") }}" method="POST" onInput="
password2.setCustomValidity(password1.value != password2.value ? 'Passwords do not match.' : ''); password2.setCustomValidity(password1.value != password2.value ? 'Passwords do not match.' : '');
password1.setCustomValidity((password1.value.length < 8 || password1.value.length == 0) ? 'Password is too short' : '') "> password1.setCustomValidity((password1.value.length < 8 || password1.value.length == 0) ? 'Password is too short' : '') ">
......
...@@ -22,11 +22,9 @@ def login(): ...@@ -22,11 +22,9 @@ def login():
username = request.form['loginname'] username = request.form['loginname']
password = request.form['password'] password = request.form['password']
conn = user_conn(username, password) conn = user_conn(username, password)
if not conn: if conn:
flash('Login name or password is wrong') conn.search(conn.user, '(objectClass=person)')
return redirect(url_for('.login')) if not conn or len(conn.entries) != 1:
conn.search(conn.user, '(objectClass=person)')
if not len(conn.entries) == 1:
flash('Login name or password is wrong') flash('Login name or password is wrong')
return render_template('login.html', ref=request.values.get('ref')) return render_template('login.html', ref=request.values.get('ref'))
user = User.from_ldap(conn.entries[0]) user = User.from_ldap(conn.entries[0])
...@@ -36,7 +34,7 @@ def login(): ...@@ -36,7 +34,7 @@ def login():
session['user_uid'] = user.uid session['user_uid'] = user.uid
session['logintime'] = datetime.datetime.now().timestamp() session['logintime'] = datetime.datetime.now().timestamp()
session['_csrf_token'] = secrets.token_hex(128) 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(): def get_current_user():
if not session.get('user_uid'): if not session.get('user_uid'):
...@@ -45,22 +43,32 @@ def get_current_user(): ...@@ -45,22 +43,32 @@ def get_current_user():
request.current_user = User.from_ldap_dn(uid_to_dn(session['user_uid'])) request.current_user = User.from_ldap_dn(uid_to_dn(session['user_uid']))
return request.current_user return request.current_user
def is_valid_session(): def login_valid():
user = get_current_user() user = get_current_user()
if not user: if not user:
return False return False
if datetime.datetime.now().timestamp() > session['logintime'] + current_app.config['SESSION_LIFETIME_SECONDS']: if datetime.datetime.now().timestamp() > session['logintime'] + current_app.config['SESSION_LIFETIME_SECONDS']:
return False return False
return True 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) bp.add_app_template_global(is_valid_session)
def login_required(group=None): def login_required(group=None, skip_mfa=False):
def wrapper(func): def wrapper(func):
@functools.wraps(func) @functools.wraps(func)
def decorator(*args, **kwargs): def decorator(*args, **kwargs):
if not is_valid_session(): if not login_valid():
flash('You need to login first') flash('You need to login first')
return redirect(url_for('session.login', ref=request.url)) 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): if not get_current_user().is_in_group(group):
flash('Access denied') flash('Access denied')
return redirect(url_for('index')) return redirect(url_for('index'))
......
...@@ -6,6 +6,7 @@ ...@@ -6,6 +6,7 @@
<div class="float-sm-right pb-2"> <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> <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("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 %} {% 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> <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 %} {% else %}
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment