Reorganize the MFA backend methods

This commit is contained in:
Joshua Tauberer 2020-09-26 09:58:25 -04:00
parent 7d6427904f
commit a8ea456b49
7 changed files with 200 additions and 222 deletions

View File

@ -1,9 +1,10 @@
import base64, os, os.path, hmac import base64, os, os.path, hmac, json
from flask import make_response from flask import make_response
import utils, totp import utils
from mailconfig import get_mail_password, get_mail_user_privileges, get_mfa_state 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_KEY_PATH = '/var/lib/mailinabox/api.key'
DEFAULT_AUTH_REALM = 'Mail-in-a-Box Management Server' DEFAULT_AUTH_REALM = 'Mail-in-a-Box Management Server'
@ -72,40 +73,29 @@ class KeyAuthService:
if username in (None, ""): if username in (None, ""):
raise ValueError("Authorization header invalid.") raise ValueError("Authorization header invalid.")
elif username == self.key: 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"]) return (None, ["admin"])
else: else:
# The user is trying to log in with a username and user-specific # The user is trying to log in with a username and either a password
# API key or password. Raises or returns privs and an indicator # (and possibly a MFA token) or a user-specific API key.
# whether the user is using their password or a user-specific API-key. return (username, self.check_user_auth(username, password, request, env))
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 def check_user_auth(self, email, pw, request, env):
if is_user_key: # Validate a user's login email address and password. If MFA is enabled,
return (username, privs) # check the MFA token in the X-Auth-Token header.
#
totp_strategy = totp.TOTPStrategy(email=username) # On success returns a list of privileges (e.g. [] or ['admin']). On login
# this will raise `totp.MissingTokenError` or `totp.BadTokenError` for bad requests # failure, raises a ValueError with a login error message.
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.
# Sanity check. # Sanity check.
if email == "" or pw == "": if email == "" or pw == "":
raise ValueError("Enter an email address and password.") 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 # The password might be a user-specific API key. create_user_key raises
# a ValueError if the user does not exist. # a ValueError if the user does not exist.
if hmac.compare_digest(self.create_user_key(email, env), pw): if hmac.compare_digest(self.create_user_key(email, env), pw):
# OK. # OK.
is_user_key = True pass
else: else:
# Get the hashed password of the user. Raise a ValueError if the # Get the hashed password of the user. Raise a ValueError if the
# email address does not correspond to a user. # email address does not correspond to a user.
@ -125,6 +115,12 @@ class KeyAuthService:
# Login failed. # Login failed.
raise ValueError("Invalid password.") 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 # 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 # 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. # 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]) if isinstance(privs, tuple): raise ValueError(privs[0])
# Return a list of privileges. # Return a list of privileges.
return (privs, is_user_key) return privs
def create_user_key(self, email, env): def create_user_key(self, email, env):
# Store an HMAC with the client. The hashed message of the HMAC will be the user's # Create a user API key, which is a shared secret that we can re-generate from
# email address & hashed password and the key will be the master API key. If TOTP # static information in our database. The shared secret contains the user's
# is active, the key will also include the TOTP secret. The user of course has their # email address, current hashed password, and current MFA state, so that the
# own email address and password. We assume they do not have the master API key # key becomes invalid if any of that information changes.
# (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 # Use an HMAC to generate the API key using our master API key as a key,
# a user's password is reset, the HMAC changes and they will correctly need to log # which also means that the API key becomes invalid when our master API key
# in to the control panel again. This method raises a ValueError if the user does # changes --- i.e. when this process is restarted.
# not exist, due to get_mail_password. #
# 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") 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') 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() return hmac.new(hash_key, msg, digestmod="sha256").hexdigest()
def _generate_key(self): def _generate_key(self):

View File

@ -5,11 +5,11 @@ from functools import wraps
from flask import Flask, request, render_template, abort, Response, send_from_directory, make_response 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_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_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_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() 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): def authorized_personnel_only(viewfunc):
@wraps(viewfunc) @wraps(viewfunc)
def newview(*args, **kwargs): 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 error = None
privs = [] privs = []
try: try:
email, privs = auth_service.authenticate(request, env) 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: except ValueError as e:
# Write a line in the log recording the failed login # Write a line in the log recording the failed login
log_failed_login(request) log_failed_login(request)
# Authentication failed. # Authentication failed.
error = "Incorrect username or password" error = str(e)
# Authorized to access an API view? # Authorized to access an API view?
if "admin" in privs: 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. # Call view func.
return viewfunc(*args, **kwargs) return viewfunc(*args, **kwargs)
elif not error:
if not error:
error = "You are not an administrator." error = "You are not an administrator."
# Not authorized. Return a 401 (send auth) and a prompt to authorize by default. # Not authorized. Return a 401 (send auth) and a prompt to authorize by default.
@ -126,27 +127,12 @@ def me():
# Is the caller authorized? # Is the caller authorized?
try: try:
email, privs = auth_service.authenticate(request, env) 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: except ValueError as e:
# Log the failed login # Log the failed login
log_failed_login(request) log_failed_login(request)
return json_response({ return json_response({
"status": "invalid", "status": "invalid",
"reason": "Incorrect username or password", "reason": str(e),
}) })
resp = { resp = {
@ -409,47 +395,33 @@ def ssl_provision_certs():
@app.route('/mfa/status', methods=['GET']) @app.route('/mfa/status', methods=['GET'])
@authorized_personnel_only @authorized_personnel_only
def two_factor_auth_get_status(): def mfa_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)
return json_response({ return json_response({
"type": None, "enabled_mfa": get_mfa_state(request.user_email, env),
"totp_secret": secret, "new_mfa": {
"totp_qr": secret_qr "totp": provision_totp(request.user_email, env)
}
}) })
@app.route('/mfa/totp/enable', methods=['POST']) @app.route('/mfa/totp/enable', methods=['POST'])
@authorized_personnel_only @authorized_personnel_only
def totp_post_enable(): def totp_post_enable():
email, _ = auth_service.authenticate(request, env)
secret = request.form.get('secret') secret = request.form.get('secret')
token = request.form.get('token') token = request.form.get('token')
if type(token) != str:
if type(secret) != str or type(token) != str or len(token) != 6 or len(secret) != 32:
return json_response({ "error": 'bad_input' }, 400) 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): @app.route('/mfa/disable', methods=['POST'])
create_totp_credential(email, secret, env)
return json_response({})
return json_response({ "error": 'token_mismatch' }, 400)
@app.route('/mfa/totp/disable', methods=['POST'])
@authorized_personnel_only @authorized_personnel_only
def totp_post_disable(): def totp_post_disable():
email, _ = auth_service.authenticate(request, env) disable_mfa(request.user_email, request.form.get('mfa-id'), env)
delete_totp_credential(email, env) return "OK"
return json_response({})
# WEB # WEB

View File

@ -547,49 +547,6 @@ def get_required_aliases(env):
return aliases 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): def kick(env, mail_result=None):
results = [] results = []
@ -651,12 +608,6 @@ def validate_password(pw):
if len(pw) < 8: if len(pw) < 8:
raise ValueError("Passwords must be at least eight characters.") 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__": if __name__ == "__main__":
import sys import sys
if len(sys.argv) > 2 and sys.argv[1] == "validate-email": if len(sys.argv) > 2 and sys.argv[1] == "validate-email":

126
management/mfa.py Normal file
View File

@ -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))

View File

@ -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

View File

@ -22,7 +22,7 @@ if [ ! -f $db_path ]; then
echo Creating new user database: $db_path; 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 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 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 fi
# ### User Authentication # ### User Authentication

View File

@ -182,9 +182,11 @@ def migration_12(env):
conn.close() conn.close()
def migration_13(env): 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') 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(): def get_current_migration():
ver = 0 ver = 0