From c2ec6e66b85a9527b148f60d8e07ae2a3331117a Mon Sep 17 00:00:00 2001 From: Joshua Tauberer Date: Sun, 30 Nov 2014 10:43:07 -0500 Subject: [PATCH] refactor management daemon authentication a) to separate authorization and b) to use 'doveadm pw' rather than 'doveadm auth test' so that it is decoupled from dovecot's login mechanism --- management/auth.py | 65 +++++++++++++++++++++++---------- management/daemon.py | 24 ++++++++---- management/mailconfig.py | 22 ++++++++++- management/templates/login.html | 7 ++++ 4 files changed, 89 insertions(+), 29 deletions(-) diff --git a/management/auth.py b/management/auth.py index dee8c7e5..ae6b20ff 100644 --- a/management/auth.py +++ b/management/auth.py @@ -3,7 +3,7 @@ import base64, os, os.path from flask import make_response import utils -from mailconfig import get_mail_user_privileges +from mailconfig import get_mail_password, get_mail_user_privileges DEFAULT_KEY_PATH = '/var/lib/mailinabox/api.key' DEFAULT_AUTH_REALM = 'Mail-in-a-Box Management Server' @@ -40,10 +40,25 @@ class KeyAuthService: with create_file_with_mode(self.key_path, 0o640) as key_file: key_file.write(self.key + '\n') - def is_authenticated(self, request, env): + def authorize_admin(self, request, env): """Test if the client key passed in HTTP Authorization header matches the service key or if the or username/password passed in the header matches an administrator user. - Returns 'OK' if the key is good or the user is an administrator, otherwise an error message.""" + Returns OK on success or another string with an error message.""" + try: + privs = self.authenticate(request, env) + except ValueError as e: + # Don't reveal whether the email address was valid on failure. + return "Invalid email address or password." + if "admin" not in privs: + return "You are not an administrator for this system." + else: + return "OK" + + def authenticate(self, request, env): + """Test if the client key passed in HTTP Authorization header matches the service key + or if the or username/password passed in the header matches an administrator user. + Returns a list of user privileges (e.g. [] or ['admin']) raise a ValueError on + login failure.""" def decode(s): return base64.b64decode(s.encode('ascii')).decode('ascii') @@ -63,46 +78,56 @@ class KeyAuthService: header = request.headers.get('Authorization') if not header: - return "No authorization header provided." + raise ValueError("No authorization header provided.") username, password = parse_basic_auth(header) if username in (None, ""): - return "Authorization header invalid." + raise ValueError("Authorization header invalid.") elif username == self.key: - return "OK" + # The user passed the API key which grants administrative privs. + return ["admin"] else: - return self.check_imap_login( username, password, env) + # The user is trying to log in with a username and password. + # Raises or returns privs. + return self.get_user_credentials(username, password, env) - def check_imap_login(self, email, pw, env): - # Validate a user's credentials. + 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. if email == "" or pw == "": - return "Enter an email address and password." + raise ValueError("Enter an email address and password.") + + # Get the hashed password of the user. Raise a ValueError if the + # email address does not correspond to a user. + pw_hash = get_mail_password(email, env) # Authenticate. try: - # Use doveadm to check credentials. doveadm will return + # Use 'doveadm pw' to check credentials. doveadm will return # a non-zero exit status if the credentials are no good, # and check_call will raise an exception in that case. utils.shell('check_call', [ - "/usr/bin/doveadm", - "auth", "test", - email, pw + "/usr/bin/doveadm", "pw", + "-p", pw, + "-t", pw_hash, ]) except: # Login failed. - return "Invalid email address or password." + raise ValueError("Invalid password.") - # Authorize. - # (This call should never fail on a valid user.) + # Get privileges for authorization. + + # (This call should never fail on a valid user. But if it did fail, it would + # return a tuple of an error message and an HTTP status code.) privs = get_mail_user_privileges(email, env) if isinstance(privs, tuple): raise Exception("Error getting privileges.") - if "admin" not in privs: - return "You are not an administrator for this system." - return "OK" + # Return a list of privileges. + return privs def _generate_key(self): raw_key = os.urandom(32) diff --git a/management/daemon.py b/management/daemon.py index a9874044..5c8bd88d 100755 --- a/management/daemon.py +++ b/management/daemon.py @@ -29,7 +29,7 @@ def authorized_personnel_only(viewfunc): @wraps(viewfunc) def newview(*args, **kwargs): # Check if the user is authorized. - authorized_status = auth_service.is_authenticated(request, env) + authorized_status = auth_service.authorize_admin(request, env) if authorized_status == "OK": # Authorized. Call view func. return viewfunc(*args, **kwargs) @@ -81,16 +81,26 @@ def index(): @app.route('/me') def me(): # Is the caller authorized? - authorized_status = auth_service.is_authenticated(request, env) - if authorized_status != "OK": + try: + privs = auth_service.authenticate(request, env) + except ValueError as e: + # Don't reveal whether the email address was valid on failure. return json_response({ "status": "not-authorized", - "reason": authorized_status, + "reason": "Invalid email address or password.", }) - return json_response({ + + resp = { "status": "authorized", - "api_key": auth_service.key, - }) + "privileges": privs, + } + + # Is authorized as admin? + if "admin" in privs: + resp["api_key"] = auth_service.key + + # Return. + return json_response(resp) # MAIL diff --git a/management/mailconfig.py b/management/mailconfig.py index 1ddcd473..82c46752 100755 --- a/management/mailconfig.py +++ b/management/mailconfig.py @@ -251,7 +251,7 @@ def add_mail_user(email, pw, privs, env): conn, c = open_database(env, with_connection=True) # hash the password - pw = utils.shell('check_output', ["/usr/bin/doveadm", "pw", "-s", "SHA512-CRYPT", "-p", pw]).strip() + pw = hash_password(pw) # add the user to the database try: @@ -287,7 +287,7 @@ def set_mail_password(email, pw, env): validate_password(pw) # hash the password - pw = utils.shell('check_output', ["/usr/bin/doveadm", "pw", "-s", "SHA512-CRYPT", "-p", pw]).strip() + pw = hash_password(pw) # update the database conn, c = open_database(env, with_connection=True) @@ -297,6 +297,24 @@ def set_mail_password(email, pw, env): conn.commit() return "OK" +def hash_password(pw): + # Turn the plain password into a Dovecot-format hashed password, meaning + # something like "{SCHEME}hashedpassworddata". + # http://wiki2.dovecot.org/Authentication/PasswordSchemes + return utils.shell('check_output', ["/usr/bin/doveadm", "pw", "-s", "SHA512-CRYPT", "-p", pw]).strip() + +def get_mail_password(email, env): + # Gets the hashed password for a user. Passwords are stored in Dovecot's + # password format, with a prefixed scheme. + # http://wiki2.dovecot.org/Authentication/PasswordSchemes + # update the database + c = open_database(env) + c.execute('SELECT password 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] + def remove_mail_user(email, env): conn, c = open_database(env, with_connection=True) c.execute("DELETE FROM users WHERE email=?", (email,)) diff --git a/management/templates/login.html b/management/templates/login.html index eedc196d..a6407050 100644 --- a/management/templates/login.html +++ b/management/templates/login.html @@ -74,6 +74,13 @@ function do_login() { // Reset any saved credentials. do_logout(); + } else if (!("api_key" in response)) { + // Login succeeded but user might not be authorized! + show_modal_error("Login Failed", "You are not an administrator on this system.") + + // Reset any saved credentials. + do_logout(); + } else { // Login succeeded.