From a8ea456b4956afbc564a1fc116c9227862142fe2 Mon Sep 17 00:00:00 2001 From: Joshua Tauberer Date: Sat, 26 Sep 2020 09:58:25 -0400 Subject: [PATCH] Reorganize the MFA backend methods --- management/auth.py | 81 +++++++++++++------------ management/daemon.py | 86 +++++++++----------------- management/mailconfig.py | 49 --------------- management/mfa.py | 126 +++++++++++++++++++++++++++++++++++++++ management/totp.py | 72 ---------------------- setup/mail-users.sh | 2 +- setup/migrate.py | 6 +- 7 files changed, 200 insertions(+), 222 deletions(-) create mode 100644 management/mfa.py delete mode 100644 management/totp.py diff --git a/management/auth.py b/management/auth.py index f3cae99..d55e069 100644 --- a/management/auth.py +++ b/management/auth.py @@ -1,9 +1,10 @@ -import base64, os, os.path, hmac +import base64, os, os.path, hmac, json from flask import make_response -import utils, totp -from mailconfig import get_mail_password, get_mail_user_privileges, get_mfa_state +import utils +from mailconfig import get_mail_password, get_mail_user_privileges +from mfa import get_mfa_state, validate_auth_mfa DEFAULT_KEY_PATH = '/var/lib/mailinabox/api.key' DEFAULT_AUTH_REALM = 'Mail-in-a-Box Management Server' @@ -72,40 +73,29 @@ class KeyAuthService: if username in (None, ""): raise ValueError("Authorization header invalid.") elif username == self.key: - # The user passed the API key which grants administrative privs. + # The user passed the master API key which grants administrative privs. 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 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) + # The user is trying to log in with a username and either a password + # (and possibly a MFA token) or a user-specific API key. + return (username, self.check_user_auth(username, password, request, env)) - # If the user is using their API key to login, 2FA has been passed before - if is_user_key: - return (username, privs) - - totp_strategy = totp.TOTPStrategy(email=username) - # this will raise `totp.MissingTokenError` or `totp.BadTokenError` for bad requests - totp_strategy.validate_request(request, 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. + def check_user_auth(self, email, pw, request, env): + # Validate a user's login email address and password. If MFA is enabled, + # check the MFA token in the X-Auth-Token header. + # + # On success returns a list of privileges (e.g. [] or ['admin']). On login + # failure, raises a ValueError 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. - is_user_key = True + pass else: # Get the hashed password of the user. Raise a ValueError if the # email address does not correspond to a user. @@ -125,6 +115,12 @@ class KeyAuthService: # Login failed. raise ValueError("Invalid password.") + # If MFA is enabled, check that MFA passes. + status, hints = validate_auth_mfa(email, request, env) + if not status: + # Login valid. Hints may have more info. + raise ValueError(",".join(hints)) + # Get privileges for authorization. This call should never fail because by this # point we know the email address is a valid user. But on error the call will # return a tuple of an error message and an HTTP status code. @@ -132,26 +128,29 @@ class KeyAuthService: if isinstance(privs, tuple): raise ValueError(privs[0]) # Return a list of privileges. - return (privs, is_user_key) + return privs def create_user_key(self, email, env): - # Store an HMAC with the client. The hashed message of the HMAC will be the user's - # email address & hashed password and the key will be the master API key. If TOTP - # is active, the key will also include the TOTP secret. The user of course has their - # own email address and password. We assume they do not have the master API key - # (unless they are trusted anyway). The HMAC proves that they authenticated with us - # in some other way to get the HMAC. Including the password means that when - # a user's password is reset, the HMAC changes and they will correctly need to log - # in to the control panel again. This method raises a ValueError if the user does - # not exist, due to get_mail_password. + # Create a user API key, which is a shared secret that we can re-generate from + # static information in our database. The shared secret contains the user's + # email address, current hashed password, and current MFA state, so that the + # key becomes invalid if any of that information changes. + # + # Use an HMAC to generate the API key using our master API key as a key, + # which also means that the API key becomes invalid when our master API key + # changes --- i.e. when this process is restarted. + # + # Raises ValueError via get_mail_password if the user doesn't exist. + + # Construct the HMAC message from the user's email address and current password. msg = b"AUTH:" + email.encode("utf8") + b" " + get_mail_password(email, env).encode("utf8") - - mfa_state = get_mfa_state(email, env) + + # Add to the message the current MFA state, which is a list of MFA information. + # Turn it into a string stably. + msg += b" " + json.dumps(get_mfa_state(email, env), sort_keys=True).encode("utf8") + + # Make the HMAC. hash_key = self.key.encode('ascii') - - if mfa_state['type'] == 'totp': - hash_key = hash_key + mfa_state['secret'].encode('ascii') - return hmac.new(hash_key, msg, digestmod="sha256").hexdigest() def _generate_key(self): diff --git a/management/daemon.py b/management/daemon.py index 0efbc03..2752593 100755 --- a/management/daemon.py +++ b/management/daemon.py @@ -5,11 +5,11 @@ from functools import wraps from flask import Flask, request, render_template, abort, Response, send_from_directory, make_response -import auth, utils, totp +import auth, utils, mfa 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_mfa_state, create_totp_credential, delete_totp_credential +from mfa import get_mfa_state, provision_totp, validate_totp_secret, enable_mfa, disable_mfa env = utils.load_environment() @@ -36,30 +36,31 @@ app = Flask(__name__, template_folder=os.path.abspath(os.path.join(os.path.dirna def authorized_personnel_only(viewfunc): @wraps(viewfunc) def newview(*args, **kwargs): - # Authenticate the passed credentials, which is either the API key or a username:password pair. + # Authenticate the passed credentials, which is either the API key or a username:password pair + # and an optional X-Auth-Token token. error = None privs = [] try: email, privs = auth_service.authenticate(request, env) - - except totp.MissingTokenError as e: - error = str(e) - except totp.BadTokenError as e: - # Write a line in the log recording the failed login - log_failed_login(request) - error = str(e) except ValueError as e: # Write a line in the log recording the failed login log_failed_login(request) + # Authentication failed. - error = "Incorrect username or password" + error = str(e) # Authorized to access an API view? if "admin" in privs: + # Store the email address of the logged in user so it can be accessed + # from the API methods that affect the calling user. + request.user_email = email + request.user_privs = privs + # Call view func. return viewfunc(*args, **kwargs) - elif not error: + + if not error: error = "You are not an administrator." # Not authorized. Return a 401 (send auth) and a prompt to authorize by default. @@ -126,27 +127,12 @@ def me(): # Is the caller authorized? try: email, privs = auth_service.authenticate(request, env) - except totp.MissingTokenError as e: - return json_response({ - "status": "missing_token", - "reason": str(e), - }) - except totp.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) - return json_response({ "status": "invalid", - "reason": "Incorrect username or password", + "reason": str(e), }) resp = { @@ -409,47 +395,33 @@ def ssl_provision_certs(): @app.route('/mfa/status', methods=['GET']) @authorized_personnel_only -def two_factor_auth_get_status(): - email, _ = auth_service.authenticate(request, env) - - mfa_state = get_mfa_state(email, env) - - if mfa_state['type'] == 'totp': - return json_response({ "type": 'totp' }) - - secret = totp.get_secret() - secret_url = totp.get_otp_uri(secret, email) - secret_qr = totp.get_qr_code(secret_url) - +def mfa_get_status(): return json_response({ - "type": None, - "totp_secret": secret, - "totp_qr": secret_qr + "enabled_mfa": get_mfa_state(request.user_email, env), + "new_mfa": { + "totp": provision_totp(request.user_email, env) + } }) @app.route('/mfa/totp/enable', methods=['POST']) @authorized_personnel_only def totp_post_enable(): - email, _ = 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: + if type(token) != str: return json_response({ "error": 'bad_input' }, 400) + try: + validate_totp_secret(secret) + enable_mfa(request.user_email, "totp", secret, token, env) + except ValueError as e: + return str(e) + return "OK" - if totp.validate(secret, token): - create_totp_credential(email, secret, env) - return json_response({}) - - return json_response({ "error": 'token_mismatch' }, 400) - -@app.route('/mfa/totp/disable', methods=['POST']) +@app.route('/mfa/disable', methods=['POST']) @authorized_personnel_only def totp_post_disable(): - email, _ = auth_service.authenticate(request, env) - delete_totp_credential(email, env) - return json_response({}) + disable_mfa(request.user_email, request.form.get('mfa-id'), env) + return "OK" # WEB diff --git a/management/mailconfig.py b/management/mailconfig.py index d25afea..47faad5 100755 --- a/management/mailconfig.py +++ b/management/mailconfig.py @@ -547,49 +547,6 @@ def get_required_aliases(env): return aliases -# multi-factor auth - -def get_mfa_state(email, env): - c = open_database(env) - c.execute('SELECT secret, mru_token FROM totp_credentials WHERE user_email=?', (email,)) - - credential_row = c.fetchone() - if credential_row is None: - return { 'type': None } - - secret, mru_token = credential_row - - return { - 'type': 'totp', - 'secret': secret, - 'mru_token': '' if mru_token is None else mru_token - } - -def create_totp_credential(email, secret, env): - validate_totp_secret(secret) - - conn, c = open_database(env, with_connection=True) - c.execute('INSERT INTO totp_credentials (user_email, secret) VALUES (?, ?)', (email, secret)) - conn.commit() - return "OK" - -def set_mru_totp_code(email, token, env): - conn, c = open_database(env, with_connection=True) - c.execute('UPDATE totp_credentials SET mru_token=? WHERE user_email=?', (token, email)) - - if c.rowcount != 1: - conn.close() - raise ValueError("That's not a user (%s)." % email) - - conn.commit() - return "OK" - -def delete_totp_credential(email, env): - conn, c = open_database(env, with_connection=True) - c.execute('DELETE FROM totp_credentials WHERE user_email=?', (email,)) - conn.commit() - return "OK" - def kick(env, mail_result=None): results = [] @@ -651,12 +608,6 @@ def validate_password(pw): if len(pw) < 8: raise ValueError("Passwords must be at least eight characters.") -def validate_totp_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 if len(sys.argv) > 2 and sys.argv[1] == "validate-email": diff --git a/management/mfa.py b/management/mfa.py new file mode 100644 index 0000000..af696ac --- /dev/null +++ b/management/mfa.py @@ -0,0 +1,126 @@ +import base64 +import hmac +import io +import os +import pyotp +import qrcode + +from mailconfig import open_database + +def get_user_id(email, c): + c.execute('SELECT id FROM users WHERE email=?', (email,)) + r = c.fetchone() + if not r: raise ValueError("User does not exist.") + return r[0] + +def get_mfa_state(email, env): + c = open_database(env) + c.execute('SELECT id, type, secret, mru_token FROM mfa WHERE user_id=?', (get_user_id(email, c),)) + return [ + { "id": r[0], "type": r[1], "secret": r[2], "mru_token": r[3] } + for r in c.fetchall() + ] + +def enable_mfa(email, type, secret, token, env): + if type == "totp": + validate_totp_secret(secret) + # Sanity check with the provide current token. + totp = pyotp.TOTP(secret) + if not totp.verify(token, valid_window=1): + raise ValueError("Invalid token.") + else: + raise ValueError("Invalid MFA type.") + + conn, c = open_database(env, with_connection=True) + c.execute('INSERT INTO mfa (user_id, type, secret) VALUES (?, ?, ?)', (get_user_id(email, c), type, secret)) + conn.commit() + +def set_mru_token(email, token, env): + conn, c = open_database(env, with_connection=True) + c.execute('UPDATE mfa SET mru_token=? WHERE user_id=?', (token, get_user_id(email, c))) + conn.commit() + +def disable_mfa(email, mfa_id, env): + conn, c = open_database(env, with_connection=True) + if mfa_id is None: + # Disable all MFA for a user. + c.execute('DELETE FROM mfa WHERE user_id=?', (get_user_id(email, c),)) + else: + # Disable a particular MFA mode for a user. + c.execute('DELETE FROM mfa WHERE user_id=? AND id=?', (get_user_id(email, c), mfa_id)) + conn.commit() + +def validate_totp_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") + +def provision_totp(email, env): + # Make a new secret. + secret = base64.b32encode(os.urandom(20)).decode('utf-8') + validate_totp_secret(secret) # sanity check + + # Make a URI that we encode within a QR code. + uri = pyotp.TOTP(secret).provisioning_uri( + name=email, + issuer_name=env["PRIMARY_HOSTNAME"] + " Mail-in-a-Box Control Panel" + ) + + # Generate a QR code as a base64-encode PNG image. + qr = qrcode.make(uri) + byte_arr = io.BytesIO() + qr.save(byte_arr, format='PNG') + png_b64 = base64.b64encode(byte_arr.getvalue()).decode('utf-8') + + return { + "type": "totp", + "secret": secret, + "qr_code_base64": png_b64 + } + +def validate_auth_mfa(email, request, env): + # Validates that a login request satisfies any MFA modes + # that have been enabled for the user's account. Returns + # a tuple (status, [hints]). status is True for a successful + # MFA login, False for a missing token. If status is False, + # hints is an array of codes that indicate what the user + # can try. Possible codes are: + # "missing-totp-token" + # "invalid-totp-token" + + mfa_state = get_mfa_state(email, env) + + # If no MFA modes are added, return True. + if len(mfa_state) == 0: + return (True, []) + + # Try the enabled MFA modes. + hints = set() + for mfa_mode in mfa_state: + if mfa_mode["type"] == "totp": + # Check that a token is present in the X-Auth-Token header. + # If not, give a hint that one can be supplied. + token = request.headers.get('x-auth-token') + if not token: + hints.add("missing-totp-token") + continue + + # Check for a replay attack. + if hmac.compare_digest(token, mfa_mode['mru_token'] or ""): + # If the token fails, skip this MFA mode. + hints.add("invalid-totp-token") + continue + + # Check the token. + totp = pyotp.TOTP(mfa_mode["secret"]) + if not totp.verify(token, valid_window=1): + hints.add("invalid-totp-token") + continue + + # On success, record the token to prevent a replay attack. + set_mru_token(email, token, env) + return (True, []) + + # On a failed login, indicate failure and any hints for what the user can do instead. + return (False, list(hints)) diff --git a/management/totp.py b/management/totp.py deleted file mode 100644 index 634305a..0000000 --- a/management/totp.py +++ /dev/null @@ -1,72 +0,0 @@ -import base64 -import hmac -import io -import os -import struct -import time -import pyotp -import qrcode -from mailconfig import get_mfa_state, set_mru_totp_code - -def get_secret(): - return base64.b32encode(os.urandom(20)).decode('utf-8') - -def get_otp_uri(secret, email): - return pyotp.TOTP(secret).provisioning_uri( - name=email, - issuer_name='mailinabox' - ) - -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 - """ - totp = pyotp.TOTP(secret) - return totp.verify(token, valid_window=1) - -class MissingTokenError(ValueError): - pass - -class BadTokenError(ValueError): - pass - -class TOTPStrategy(): - def __init__(self, email): - self.type = 'totp' - self.email = email - - def store_successful_login(self, token, env): - return set_mru_totp_code(self.email, token, env) - - def validate_request(self, request, env): - mfa_state = get_mfa_state(self.email, env) - - # 2FA is not enabled, we can skip further checks - if mfa_state['type'] != 'totp': - return True - - # 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 not 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 hmac.compare_digest(token_header, mfa_state['mru_token']) or validate(mfa_state['secret'], token_header) != True: - raise BadTokenError("Two factor code incorrect") - - self.store_successful_login(token_header, env) - return True diff --git a/setup/mail-users.sh b/setup/mail-users.sh index ea1f975..9fcdf79 100755 --- a/setup/mail-users.sh +++ b/setup/mail-users.sh @@ -22,7 +22,7 @@ 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; echo "CREATE TABLE aliases (id INTEGER PRIMARY KEY AUTOINCREMENT, source TEXT NOT NULL UNIQUE, destination TEXT NOT NULL, permitted_senders TEXT);" | sqlite3 $db_path; - echo "CREATE TABLE totp_credentials (id INTEGER PRIMARY KEY AUTOINCREMENT, user_email TEXT NOT NULL UNIQUE, secret TEXT NOT NULL, mru_token TEXT, FOREIGN KEY (user_email) REFERENCES users(email) ON DELETE CASCADE);" | sqlite3 $db_path; + echo "CREATE TABLE IF NOT EXISTS mfa (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL UNIQUE, type TEXT NOT NULL, secret TEXT NOT NULL, mru_token TEXT, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE);" | sqlite3 $db_path; fi # ### User Authentication diff --git a/setup/migrate.py b/setup/migrate.py index 454eb43..5b6e398 100755 --- a/setup/migrate.py +++ b/setup/migrate.py @@ -182,9 +182,11 @@ def migration_12(env): conn.close() def migration_13(env): - # Add a table for `totp_credentials` + # Add the "mfa" table for configuring MFA for login to the control panel. db = os.path.join(env["STORAGE_ROOT"], 'mail/users.sqlite') - shell("check_call", ["sqlite3", db, "CREATE TABLE IF NOT EXISTS totp_credentials (id INTEGER PRIMARY KEY AUTOINCREMENT, user_email TEXT NOT NULL UNIQUE, secret TEXT NOT NULL, mru_token TEXT, FOREIGN KEY (user_email) REFERENCES users(email) ON DELETE CASCADE);"]) + shell("check_call", ["sqlite3", db, "CREATE TABLE IF NOT EXISTS mfa (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL UNIQUE, type TEXT NOT NULL, secret TEXT NOT NULL, mru_token TEXT, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE);"]) + +########################################################### def get_current_migration(): ver = 0