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 @@
  • Custom DNS
  • External DNS
  • +
  • Two Factor Authentication
  • Munin Monitoring
  • @@ -131,7 +132,11 @@ {% include "custom-dns.html" %} -
    +
    + {% include "two-factor-auth.html" %} +
    + +
    {% include "login.html" %}
    diff --git a/management/templates/two-factor-auth.html b/management/templates/two-factor-auth.html new file mode 100644 index 0000000..9f1a8b5 --- /dev/null +++ b/management/templates/two-factor-auth.html @@ -0,0 +1,220 @@ + + +

    Two Factor Authentication

    + +
    +
    Loading...
    + +
    +
    +

    Setup

    +

    After enabling two factor authentication, any login to the admin panel will require you to enter a time-limited 6-digit number from an authenticator app after entering your normal credentials.

    +

    1. Scan the QR code or enter the secret into an authenticator app (e.g. Google Authenticator)

    +
    +
    + +
    + + +
    + + + +
    + +
    +
    + +
    +
    +

    Two factor authentication is active.

    +
    + + +
    + +
    +
    +
    +
    + + diff --git a/management/totp.py b/management/totp.py new file mode 100644 index 0000000..52cdc25 --- /dev/null +++ b/management/totp.py @@ -0,0 +1,51 @@ +import base64 +import hmac +import io +import os +import struct +import time +from urllib.parse import quote +import qrcode + +def get_secret(): + return base64.b32encode(os.urandom(20)).decode('utf-8') + +def get_otp_uri(secret, email): + site_name = 'mailinabox' + + return 'otpauth://totp/{}:{}?secret={}&issuer={}'.format( + quote(site_name), + quote(email), + secret, + quote(site_name) + ) + +def get_qr_code(data): + qr = qrcode.make(data) + byte_arr = io.BytesIO() + qr.save(byte_arr, format='PNG') + + encoded = base64.b64encode(byte_arr.getvalue()).decode('utf-8') + return 'data:image/png;base64,{}'.format(encoded) + +def validate(secret, token): + """ + @see https://tools.ietf.org/html/rfc6238#section-4 + @see https://tools.ietf.org/html/rfc4226#section-5.4 + @see https://git.sr.ht/~sircmpwn/meta.sr.ht/tree/master/metasrht/totp.py + @see https://github.com/susam/mintotp/blob/master/mintotp.py + TODO: resynchronisation + """ + key = base64.b32decode(secret) + tm = int(time.time() / 30) + digits = 6 + + step = 0 + counter = struct.pack('>Q', tm + step) + + hm = hmac.HMAC(key, counter, 'sha1').digest() + offset = hm[-1] &0x0F + binary = struct.unpack(">L", hm[offset:offset + 4])[0] & 0x7fffffff + + code = str(binary)[-digits:].rjust(digits, '0') + return token == code diff --git a/setup/mail-users.sh b/setup/mail-users.sh index e54485b..3047489 100755 --- a/setup/mail-users.sh +++ b/setup/mail-users.sh @@ -20,7 +20,8 @@ db_path=$STORAGE_ROOT/mail/users.sqlite # Create an empty database if it doesn't yet exist. if [ ! -f $db_path ]; then echo Creating new user database: $db_path; - echo "CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, email TEXT NOT NULL UNIQUE, password TEXT NOT NULL, extra, privileges TEXT NOT NULL DEFAULT '');" | sqlite3 $db_path; + # TODO: Add migration + echo "CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, email TEXT NOT NULL UNIQUE, password TEXT NOT NULL, extra, privileges TEXT NOT NULL DEFAULT '', two_factor_secret TEXT, two_factor_last_used_token TEXT);" | sqlite3 $db_path; echo "CREATE TABLE aliases (id INTEGER PRIMARY KEY AUTOINCREMENT, source TEXT NOT NULL UNIQUE, destination TEXT NOT NULL, permitted_senders TEXT);" | sqlite3 $db_path; fi diff --git a/setup/management.sh b/setup/management.sh index 4b398aa..ce78b17 100755 --- a/setup/management.sh +++ b/setup/management.sh @@ -50,6 +50,7 @@ hide_output $venv/bin/pip install --upgrade pip hide_output $venv/bin/pip install --upgrade \ rtyaml "email_validator>=1.0.0" "exclusiveprocess" \ flask dnspython python-dateutil \ + qrcode[pil] \ "idna>=2.0.0" "cryptography==2.2.2" boto psutil postfix-mta-sts-resolver # CONFIGURATION