From a7a66929aac237777c5916eee9c0f86eeac53735 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felix=20Sp=C3=B6ttel?= <1682504+fspoettel@users.noreply.github.com> Date: Wed, 2 Sep 2020 16:48:23 +0200 Subject: [PATCH] add user interface for managing 2fa * update user schema with 2fa columns --- management/daemon.py | 55 +++++- management/mailconfig.py | 40 ++++ management/templates/index.html | 7 +- management/templates/two-factor-auth.html | 220 ++++++++++++++++++++++ management/totp.py | 51 +++++ setup/mail-users.sh | 3 +- setup/management.sh | 1 + 7 files changed, 370 insertions(+), 7 deletions(-) create mode 100644 management/templates/two-factor-auth.html create mode 100644 management/totp.py diff --git a/management/daemon.py b/management/daemon.py index b7bf2a6..ebf112f 100755 --- a/management/daemon.py +++ b/management/daemon.py @@ -1,14 +1,15 @@ import os, os.path, re, json, time -import subprocess +import multiprocessing.pool, subprocess from functools import wraps from flask import Flask, request, render_template, abort, Response, send_from_directory, make_response -import auth, utils, multiprocessing.pool +import auth, utils, totp from mailconfig import get_mail_users, get_mail_users_ex, get_admins, add_mail_user, set_mail_password, remove_mail_user from mailconfig import get_mail_user_privileges, add_remove_mail_user_privilege from mailconfig import get_mail_aliases, get_mail_aliases_ex, get_mail_domains, add_mail_alias, remove_mail_alias +from mailconfig import get_two_factor_info, set_two_factor_secret, remove_two_factor_secret env = utils.load_environment() @@ -83,8 +84,8 @@ def authorized_personnel_only(viewfunc): def unauthorized(error): return auth_service.make_unauthorized_response() -def json_response(data): - return Response(json.dumps(data, indent=2, sort_keys=True)+'\n', status=200, mimetype='application/json') +def json_response(data, status=200): + return Response(json.dumps(data, indent=2, sort_keys=True)+'\n', status=status, mimetype='application/json') ################################### @@ -334,7 +335,7 @@ def ssl_get_status(): # What domains can we provision certificates for? What unexpected problems do we have? provision, cant_provision = get_certificates_to_provision(env, show_valid_certs=False) - + # What's the current status of TLS certificates on all of the domain? domains_status = get_web_domains_info(env) domains_status = [ @@ -383,6 +384,50 @@ def ssl_provision_certs(): requests = provision_certificates(env, limit_domains=None) return json_response({ "requests": requests }) +# Two Factor Auth + +@app.route('/two-factor-auth/status', methods=['GET']) +@authorized_personnel_only +def two_factor_auth_get_status(): + email, privs = auth_service.authenticate(request, env) + two_factor_secret, two_factor_token = get_two_factor_info(email, env) + + if two_factor_secret != None: + return json_response({ 'status': 'on' }) + + secret = totp.get_secret() + secret_url = totp.get_otp_uri(secret, email) + secret_qr = totp.get_qr_code(secret_url) + + return json_response({ + "status": 'off', + "secret": secret, + "qr_code": secret_qr + }) + +@app.route('/two-factor-auth/setup', methods=['POST']) +@authorized_personnel_only +def two_factor_auth_post_setup(): + email, privs = auth_service.authenticate(request, env) + + secret = request.form.get('secret') + token = request.form.get('token') + + if type(secret) != str or type(token) != str or len(token) != 6 or len(secret) != 32: + return json_response({ "error": 'bad_input' }, 400) + + if (totp.validate(secret, token)): + set_two_factor_secret(email, secret, token, env) + return json_response({}) + + return json_response({ "error": 'token_mismatch' }, 400) + +@app.route('/two-factor-auth/disable', methods=['POST']) +@authorized_personnel_only +def two_factor_auth_post_disable(): + email, privs = auth_service.authenticate(request, env) + remove_two_factor_secret(email, env) + return json_response({}) # WEB diff --git a/management/mailconfig.py b/management/mailconfig.py index b061ea7..3bc4889 100755 --- a/management/mailconfig.py +++ b/management/mailconfig.py @@ -547,6 +547,41 @@ def get_required_aliases(env): return aliases +def get_two_factor_info(email, env): + c = open_database(env) + + c.execute('SELECT two_factor_secret, two_factor_last_used_token FROM users WHERE email=?', (email,)) + rows = c.fetchall() + if len(rows) != 1: + raise ValueError("That's not a user (%s)." % email) + return (rows[0][0], rows[0][1]) + +def set_two_factor_secret(email, secret, token, env): + validate_two_factor_secret(secret) + + conn, c = open_database(env, with_connection=True) + c.execute("UPDATE users SET two_factor_secret=?, two_factor_last_used_token=? WHERE email=?", (secret, token, email)) + if c.rowcount != 1: + raise ValueError("That's not a user (%s)." % email) + conn.commit() + return "OK" + +def set_two_factor_last_used_token(email, token, env): + conn, c = open_database(env, with_connection=True) + c.execute("UPDATE users SET two_factor_last_used_token=? WHERE email=?", (token, email)) + if c.rowcount != 1: + raise ValueError("That's not a user (%s)." % email) + conn.commit() + return "OK" + +def remove_two_factor_secret(email, env): + conn, c = open_database(env, with_connection=True) + c.execute("UPDATE users SET two_factor_secret=null, two_factor_last_used_token=null WHERE email=?", (email,)) + if c.rowcount != 1: + raise ValueError("That's not a user (%s)." % email) + conn.commit() + return "OK" + def kick(env, mail_result=None): results = [] @@ -608,6 +643,11 @@ def validate_password(pw): if len(pw) < 8: raise ValueError("Passwords must be at least eight characters.") +def validate_two_factor_secret(secret): + if type(secret) != str or secret.strip() == "": + raise ValueError("No secret provided.") + if len(secret) != 32: + raise ValueError("Secret should be a 32 characters base32 string") if __name__ == "__main__": import sys diff --git a/management/templates/index.html b/management/templates/index.html index 2c0d5a9..3088ef6 100644 --- a/management/templates/index.html +++ b/management/templates/index.html @@ -93,6 +93,7 @@