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 01/27] 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 b7bf2a66..ebf112f6 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 b061ea7d..3bc48897 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 2c0d5a9a..3088ef63 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 00000000..9f1a8b5a --- /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 00000000..52cdc256 --- /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 e54485bb..3047489b 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 4b398aa2..ce78b171 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 From 3c3683429b0774bad9210829cbe0caf43d3dc14d 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 17:23:32 +0200 Subject: [PATCH 02/27] implement two factor check during login --- management/auth.py | 48 +++++++++++++++++++--- management/daemon.py | 34 ++++++++++++++-- management/templates/index.html | 4 +- management/templates/login.html | 70 ++++++++++++++++++++++++++------- 4 files changed, 130 insertions(+), 26 deletions(-) diff --git a/management/auth.py b/management/auth.py index 55f59664..83d9c1d6 100644 --- a/management/auth.py +++ b/management/auth.py @@ -2,12 +2,19 @@ import base64, os, os.path, hmac from flask import make_response -import utils +import utils, totp from mailconfig import get_mail_password, get_mail_user_privileges +from mailconfig import get_two_factor_info, set_two_factor_last_used_token DEFAULT_KEY_PATH = '/var/lib/mailinabox/api.key' DEFAULT_AUTH_REALM = 'Mail-in-a-Box Management Server' +class MissingTokenError(ValueError): + pass + +class BadTokenError(ValueError): + pass + class KeyAuthService: """Generate an API key for authenticating clients @@ -76,23 +83,52 @@ class KeyAuthService: return (None, ["admin"]) else: # The user is trying to log in with a username and user-specific - # API key or password. Raises or returns privs. - return (username, self.get_user_credentials(username, password, env)) + # API key or password. Raises or returns privs and an indicator + # whether the user is using their password or a user-specific API-key. + privs, is_user_key = self.get_user_credentials(username, password, env) + + # If the user is using their API key to login, 2FA has been passed before + if is_user_key: + return (username, privs) + + secret, last_token = get_two_factor_info(username, env) + + # 2FA is not enabled, we can skip further checks + if secret == "" or secret == None: + return (username, privs) + + # If 2FA is enabled, raise if: + # 1. no token is provided via `x-auth-token` + # 2. a previously supplied token is used (to counter replay attacks) + # 3. the token is invalid + # in that case, we need to raise and indicate to the client to supply a TOTP + token_header = request.headers.get('x-auth-token') + if token_header == None or token_header == "": + raise MissingTokenError("Two factor code missing (no x-auth-token supplied)") + + # TODO: Should a token replay be handled as its own error? + if token_header == last_token or totp.validate(secret, token_header) != True: + raise BadTokenError("Two factor code incorrect") + + set_two_factor_last_used_token(username, token_header, env) + return (username, privs) def get_user_credentials(self, email, pw, env): # Validate a user's credentials. On success returns a list of # privileges (e.g. [] or ['admin']). On failure raises a ValueError - # with a login error message. + # with a login error message. # Sanity check. if email == "" or pw == "": raise ValueError("Enter an email address and password.") + is_user_key = False + # The password might be a user-specific API key. create_user_key raises # a ValueError if the user does not exist. if hmac.compare_digest(self.create_user_key(email, env), pw): # OK. - pass + is_user_key = True else: # Get the hashed password of the user. Raise a ValueError if the # email address does not correspond to a user. @@ -119,7 +155,7 @@ class KeyAuthService: if isinstance(privs, tuple): raise ValueError(privs[0]) # Return a list of privileges. - return privs + return (privs, is_user_key) def create_user_key(self, email, env): # Store an HMAC with the client. The hashed message of the HMAC will be the user's diff --git a/management/daemon.py b/management/daemon.py index ebf112f6..b80b1e73 100755 --- a/management/daemon.py +++ b/management/daemon.py @@ -40,14 +40,23 @@ def authorized_personnel_only(viewfunc): error = None try: email, privs = auth_service.authenticate(request, env) + except auth.MissingTokenError as e: + privs = [] + error = str(e) + except auth.BadTokenError as e: + # Write a line in the log recording the failed login + log_failed_login(request) + + privs = [] + error = str(e) except ValueError as e: + # Write a line in the log recording the failed login + log_failed_login(request) + # Authentication failed. privs = [] error = "Incorrect username or password" - # Write a line in the log recording the failed login - log_failed_login(request) - # Authorized to access an API view? if "admin" in privs: # Call view func. @@ -119,6 +128,23 @@ def me(): # Is the caller authorized? try: email, privs = auth_service.authenticate(request, env) + except auth.MissingTokenError as e: + # Log the failed login + log_failed_login(request) + + return json_response({ + "status": "missing_token", + "reason": str(e), + }) + except auth.BadTokenError as e: + # Log the failed login + log_failed_login(request) + + return json_response({ + "status": "bad_token", + "reason": str(e), + }) + except ValueError as e: # Log the failed login log_failed_login(request) @@ -126,7 +152,7 @@ def me(): return json_response({ "status": "invalid", "reason": "Incorrect username or password", - }) + }) resp = { "status": "ok", diff --git a/management/templates/index.html b/management/templates/index.html index 3088ef63..b0d86dd3 100644 --- a/management/templates/index.html +++ b/management/templates/index.html @@ -297,7 +297,7 @@ function ajax_with_indicator(options) { } var api_credentials = ["", ""]; -function api(url, method, data, callback, callback_error) { +function api(url, method, data, callback, callback_error, headers) { // from http://www.webtoolkit.info/javascript-base64.html function base64encode(input) { _keyStr = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/="; @@ -335,7 +335,7 @@ function api(url, method, data, callback, callback_error) { method: method, cache: false, data: data, - + headers: headers, // the custom DNS api sends raw POST/PUT bodies --- prevent URL-encoding processData: typeof data != "string", mimeType: typeof data == "string" ? "text/plain; charset=ascii" : null, diff --git a/management/templates/login.html b/management/templates/login.html index b6e74df6..0322dd5f 100644 --- a/management/templates/login.html +++ b/management/templates/login.html @@ -1,4 +1,29 @@ -

    {{hostname}}

    + + +

    {{hostname}}

    {% if no_users_exist or no_admins_exist %}
    @@ -20,10 +45,10 @@ sudo tools/mail.py user make-admin me@{{hostname}}
    {% endif %} -

    Log in here for your Mail-in-a-Box control panel.

    +

    Log in here for your Mail-in-a-Box control panel.

    -
    -
    + +
    + +
    + +
    +
    @@ -53,7 +84,6 @@ sudo tools/mail.py user make-admin me@{{hostname}}
    -