diff --git a/api/mailinabox.yml b/api/mailinabox.yml index 14cf54de..bd4b203b 100644 --- a/api/mailinabox.yml +++ b/api/mailinabox.yml @@ -54,24 +54,24 @@ tags: System operations, which include system status checks, new version checks and reboot status. paths: - /me: - get: + /login: + post: tags: - User - summary: Get user information + summary: Exchange a username and password for a session API key. description: | - Returns user information. Used for user authentication. + Returns user information and a session API key. Authenticate a user by supplying the auth token as a base64 encoded string in format `email:password` using basic authentication headers. If successful, a long-lived `api_key` is returned which can be used for subsequent - requests to the API. - operationId: getMe + requests to the API in place of the password. + operationId: login x-codeSamples: - lang: curl source: | - curl -X GET "https://{host}/admin/me" \ + curl -X GET "https://{host}/admin/login" \ -u ":" responses: 200: @@ -92,6 +92,24 @@ paths: privileges: - admin status: ok + /logout: + post: + tags: + - User + summary: Invalidates a session API key. + description: | + Invalidates a session API key so that it cannot be used after this API call. + operationId: logout + x-codeSamples: + - lang: curl + source: | + curl -X GET "https://{host}/admin/logout" \ + -u ":" + responses: + 200: + description: Successful operation + content: + application/json: /system/status: post: tags: @@ -1803,7 +1821,7 @@ components: The `access-token` is comprised of the Base64 encoding of `username:password`. The `username` is the mail user's email address, and `password` can either be the mail user's - password, or the `api_key` returned from the `getMe` operation. + password, or the `api_key` returned from the `login` operation. When using `curl`, you can supply user credentials using the `-u` or `--user` parameter. requestBodies: diff --git a/management/auth.py b/management/auth.py index 46768684..683446a0 100644 --- a/management/auth.py +++ b/management/auth.py @@ -1,6 +1,8 @@ -import base64, os, os.path, hmac, json +# -*- indent-tabs-mode: t; tab-width: 4; python-indent-offset: 4; -*- +import base64, os, os.path, hmac, json, secrets +from datetime import timedelta -from flask import make_response +from expiringdict import ExpiringDict import utils from mailconfig import validate_login, get_mail_password, get_mail_user_privileges @@ -9,25 +11,18 @@ from mfa import get_hash_mfa_state, validate_auth_mfa DEFAULT_KEY_PATH = '/var/lib/mailinabox/api.key' DEFAULT_AUTH_REALM = 'Mail-in-a-Box Management Server' -class KeyAuthService: - """Generate an API key for authenticating clients - - Clients must read the key from the key file and send the key with all HTTP - requests. The key is passed as the username field in the standard HTTP - Basic Auth header. - """ +class AuthService: def __init__(self): self.auth_realm = DEFAULT_AUTH_REALM - self.key = self._generate_key() self.key_path = DEFAULT_KEY_PATH + self.max_session_duration = timedelta(days=2) - def write_key(self): - """Write key to file so authorized clients can get the key + self.init_system_api_key() + self.sessions = ExpiringDict(max_len=64, max_age_seconds=self.max_session_duration.total_seconds()) + + def init_system_api_key(self): + """Write an API key to a local file so local processes can use the API""" - The key file is created with mode 0640 so that additional users can be - authorized to access the API by granting group/ACL read permissions on - the key file. - """ def create_file_with_mode(path, mode): # Based on answer by A-B-B: http://stackoverflow.com/a/15015748 old_umask = os.umask(0) @@ -36,111 +31,118 @@ class KeyAuthService: finally: os.umask(old_umask) + self.key = secrets.token_hex(32) + os.makedirs(os.path.dirname(self.key_path), exist_ok=True) with create_file_with_mode(self.key_path, 0o640) as key_file: key_file.write(self.key + '\n') - 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. + def authenticate(self, request, env, login_only=False, logout=False): + """Test if the HTTP Authorization header's username matches the system key, a session key, + or if the username/password passed in the header matches a local user. Returns a tuple of the user's email address and list of user privileges (e.g. ('my@email', []) or ('my@email', ['admin']); raises a ValueError on login failure. - If the user used an API key, the user's email is returned as None.""" + If the user used the system API key, the user's email is returned as None since + this key is not associated with a user.""" - def decode(s): - return base64.b64decode(s.encode('ascii')).decode('ascii') - - def parse_basic_auth(header): + def parse_http_authorization_basic(header): + def decode(s): + return base64.b64decode(s.encode('ascii')).decode('ascii') if " " not in header: return None, None scheme, credentials = header.split(maxsplit=1) if scheme != 'Basic': return None, None - credentials = decode(credentials) if ":" not in credentials: return None, None username, password = credentials.split(':', maxsplit=1) return username, password - header = request.headers.get('Authorization') - if not header: - raise ValueError("No authorization header provided.") - - username, password = parse_basic_auth(header) - + username, password = parse_http_authorization_basic(request.headers.get('Authorization', '')) if username in (None, ""): raise ValueError("Authorization header invalid.") - elif username == self.key: - # The user passed the master API key which grants administrative privs. + + if username.strip() == "" and password.strip() == "": + raise ValueError("No email address, password, session key, or API key provided.") + + # If user passed the system API key, grant administrative privs. This key + # is not associated with a user. + if username == self.key and not login_only: return (None, ["admin"]) + + # If the password corresponds with a session token for the user, grant access for that user. + if password in self.sessions and self.sessions[password]["email"] == username and not login_only: + sessionid = password + session = self.sessions[sessionid] + if session["password_token"] != self.create_user_password_state_token(username, env): + # This session is invalid because the user's password/MFA state changed + # after the session was created. + del self.sessions[sessionid] + raise ValueError("Session expired.") + if logout: + # Clear the session. + del self.sessions[sessionid] + else: + # Re-up the session so that it does not expire. + self.sessions[sessionid] = session + + # If no password was given, but a username was given, we're missing some information. + elif password.strip() == "": + raise ValueError("Enter a password.") + else: - # 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)) + # The user is trying to log in with a username and a password + # (and possibly a MFA token). On failure, an exception is raised. + self.check_user_auth(username, password, request, env) + + # Get privileges for authorization. This call should never fail because by this + # point we know the email address is a valid user --- unless the user has been + # deleted after the session was granted. On error the call will return a tuple + # of an error message and an HTTP status code. + privs = get_mail_user_privileges(username, env) + if isinstance(privs, tuple): raise ValueError(privs[0]) + + # Return the authorization information. + return (username, privs) 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. + # On login failure, raises a ValueError with a login error message. On + # success, nothing is returned. - # Sanity check. - if email == "" or pw == "": - raise ValueError("Enter an email address and password.") + # Authenticate. + if not validate_login(email, pw, env): + # Login failed. + raise ValueError("Incorrect email address or password.") - # 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 - else: - # Get the hashed password of the user. Raise a ValueError if the - # email address does not correspond to a user. - if not validate_login(email, pw, env): - # 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)) - # 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. - privs = get_mail_user_privileges(email, env) - if isinstance(privs, tuple): raise ValueError(privs[0]) - - # Return a list of privileges. - return privs - - def create_user_key(self, email, env): - # 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" " + ";".join(get_mail_password(email, env)).encode("utf8") + def create_user_password_state_token(self, email, env): + # Create a token that changes if the user's password or MFA options change + # so that sessions become invalid if any of that information changes. + msg = ';'.join(get_mail_password(email, env)).encode("utf8") # 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_hash_mfa_state(email, env), sort_keys=True).encode("utf8") - # Make the HMAC. + # Make a HMAC using the system API key as a hash key. hash_key = self.key.encode('ascii') return hmac.new(hash_key, msg, digestmod="sha256").hexdigest() - def _generate_key(self): - raw_key = os.urandom(32) - return base64.b64encode(raw_key).decode('ascii') + def create_session_key(self, username, env, type=None): + # Create a new session. + token = secrets.token_hex(32) + self.sessions[token] = { + "email": username, + "password_token": self.create_user_password_state_token(username, env), + } + return token diff --git a/management/daemon.py b/management/daemon.py index 542443ce..49d4d891 100755 --- a/management/daemon.py +++ b/management/daemon.py @@ -1,5 +1,8 @@ #!/usr/local/lib/mailinabox/env/bin/python3 # +# The API can be accessed on the command line, e.g. use `curl` like so: +# curl --user $( @@ -83,7 +86,7 @@
+
+ {% include "welcome.html" %} +
+
{% include "system-status.html" %}
@@ -304,7 +312,7 @@ function ajax_with_indicator(options) { return false; // handy when called from onclick } -var api_credentials = ["", ""]; +var api_credentials = null; function api(url, method, data, callback, callback_error, headers) { // from http://www.webtoolkit.info/javascript-base64.html function base64encode(input) { @@ -352,9 +360,10 @@ function api(url, method, data, callback, callback_error, headers) { // We don't store user credentials in a cookie to avoid the hassle of CSRF // attacks. The Authorization header only gets set in our AJAX calls triggered // by user actions. - xhr.setRequestHeader( - 'Authorization', - 'Basic ' + base64encode(api_credentials[0] + ':' + api_credentials[1])); + if (api_credentials) + xhr.setRequestHeader( + 'Authorization', + 'Basic ' + base64encode(api_credentials.username + ':' + api_credentials.session_key)); }, success: callback, error: callback_error || default_error, @@ -373,12 +382,21 @@ var current_panel = null; var switch_back_to_panel = null; function do_logout() { - api_credentials = ["", ""]; + // Clear the session from the backend. + api("/logout", "POST"); + + // Forget the token. + api_credentials = null; if (typeof localStorage != 'undefined') localStorage.removeItem("miab-cp-credentials"); if (typeof sessionStorage != 'undefined') sessionStorage.removeItem("miab-cp-credentials"); + + // Return to the start. show_panel('login'); + + // Reset menus. + show_hide_menus(); } function show_panel(panelid) { @@ -401,14 +419,22 @@ function show_panel(panelid) { $(function() { // Recall saved user credentials. - if (typeof sessionStorage != 'undefined' && sessionStorage.getItem("miab-cp-credentials")) - api_credentials = sessionStorage.getItem("miab-cp-credentials").split(":"); - else if (typeof localStorage != 'undefined' && localStorage.getItem("miab-cp-credentials")) - api_credentials = localStorage.getItem("miab-cp-credentials").split(":"); + try { + if (typeof sessionStorage != 'undefined' && sessionStorage.getItem("miab-cp-credentials")) + api_credentials = JSON.parse(sessionStorage.getItem("miab-cp-credentials")); + else if (typeof localStorage != 'undefined' && localStorage.getItem("miab-cp-credentials")) + api_credentials = JSON.parse(localStorage.getItem("miab-cp-credentials")); + } catch (_) { + } + + // Toggle menu state. + show_hide_menus(); // Recall what the user was last looking at. - if (typeof localStorage != 'undefined' && localStorage.getItem("miab-cp-lastpanel")) { + if (api_credentials != null && typeof localStorage != 'undefined' && localStorage.getItem("miab-cp-lastpanel")) { show_panel(localStorage.getItem("miab-cp-lastpanel")); + } else if (api_credentials != null) { + show_panel('welcome'); } else { show_panel('login'); } diff --git a/management/templates/login.html b/management/templates/login.html index 19b23d3a..421c8845 100644 --- a/management/templates/login.html +++ b/management/templates/login.html @@ -102,11 +102,11 @@ function do_login() { } // Exchange the email address & password for an API key. - api_credentials = [$('#loginEmail').val(), $('#loginPassword').val()] + api_credentials = { username: $('#loginEmail').val(), session_key: $('#loginPassword').val() } api( - "/me", - "GET", + "/login", + "POST", {}, function(response) { // This API call always succeeds. It returns a JSON object indicating @@ -141,7 +141,9 @@ function do_login() { // Login succeeded. // Save the new credentials. - api_credentials = [response.email, response.api_key]; + api_credentials = { username: response.email, + session_key: response.api_key, + privileges: response.privileges }; // Try to wipe the username/password information. $('#loginEmail').val(''); @@ -152,18 +154,21 @@ function do_login() { // Remember the credentials. if (typeof localStorage != 'undefined' && typeof sessionStorage != 'undefined') { if ($('#loginRemember').val()) { - localStorage.setItem("miab-cp-credentials", api_credentials.join(":")); + localStorage.setItem("miab-cp-credentials", JSON.stringify(api_credentials)); sessionStorage.removeItem("miab-cp-credentials"); } else { localStorage.removeItem("miab-cp-credentials"); - sessionStorage.setItem("miab-cp-credentials", api_credentials.join(":")); + sessionStorage.setItem("miab-cp-credentials", JSON.stringify(api_credentials)); } } + // Toggle menus. + show_hide_menus(); + // Open the next panel the user wants to go to. Do this after the XHR response // is over so that we don't start a new XHR request while this one is finishing, // which confuses the loading indicator. - setTimeout(function() { show_panel(!switch_back_to_panel || switch_back_to_panel == "login" ? 'system_status' : switch_back_to_panel) }, 300); + setTimeout(function() { show_panel(!switch_back_to_panel || switch_back_to_panel == "login" ? 'welcome' : switch_back_to_panel) }, 300); } }, undefined, @@ -183,4 +188,19 @@ function show_login() { } }); } + +function show_hide_menus() { + var is_logged_in = (api_credentials != null); + var privs = api_credentials ? api_credentials.privileges : []; + $('.if-logged-in').toggle(is_logged_in); + $('.if-logged-in-admin, .if-logged-in-not-admin').toggle(false); + if (is_logged_in) { + $('.if-logged-in-not-admin').toggle(true); + privs.forEach(function(priv) { + $('.if-logged-in-' + priv).toggle(true); + $('.if-logged-in-not-' + priv).toggle(false); + }); + } + $('.if-not-logged-in').toggle(!is_logged_in); +} diff --git a/management/templates/users.html b/management/templates/users.html index 9a946648..705f4587 100644 --- a/management/templates/users.html +++ b/management/templates/users.html @@ -222,7 +222,7 @@ function users_set_password(elem) { var email = $(elem).parents('tr').attr('data-email'); var yourpw = ""; - if (api_credentials != null && email == api_credentials[0]) + if (api_credentials != null && email == api_credentials.username) yourpw = "

If you change your own password, you will be logged out of this control panel and will need to log in again.

"; show_modal_confirm( @@ -276,7 +276,7 @@ function users_remove(elem) { var email = $(elem).parents('tr').attr('data-email'); // can't remove yourself - if (api_credentials != null && email == api_credentials[0]) { + if (api_credentials != null && email == api_credentials.username) { show_modal_error("Archive User", "You cannot archive your own account."); return; } @@ -308,7 +308,7 @@ function mod_priv(elem, add_remove) { var priv = $(elem).parents('td').find('.name').text(); // can't remove your own admin access - if (priv == "admin" && add_remove == "remove" && api_credentials != null && email == api_credentials[0]) { + if (priv == "admin" && add_remove == "remove" && api_credentials != null && email == api_credentials.username) { show_modal_error("Modify Privileges", "You cannot remove the admin privilege from yourself."); return; } diff --git a/management/templates/welcome.html b/management/templates/welcome.html new file mode 100644 index 00000000..124d2d28 --- /dev/null +++ b/management/templates/welcome.html @@ -0,0 +1,16 @@ + + +

{{hostname}}

+ +

Welcome to your Mail-in-a-Box control panel.

+ diff --git a/setup/management.sh b/setup/management.sh index 83b2ffac..3cc2d470 100755 --- a/setup/management.sh +++ b/setup/management.sh @@ -49,7 +49,7 @@ hide_output $venv/bin/pip install --upgrade pip # NOTE: email_validator is repeated in setup/questions.sh, so please keep the versions synced. hide_output $venv/bin/pip install --upgrade \ rtyaml "email_validator>=1.0.0" "exclusiveprocess" \ - flask dnspython python-dateutil \ + flask dnspython python-dateutil expiringdict \ qrcode[pil] pyotp \ "idna>=2.0.0" "cryptography==2.2.2" boto psutil postfix-mta-sts-resolver b2sdk ldap3 diff --git a/setup/webmail.sh b/setup/webmail.sh index c3c7262f..c846ab51 100755 --- a/setup/webmail.sh +++ b/setup/webmail.sh @@ -30,8 +30,8 @@ apt_install \ # Combine the Roundcube version number with the commit hash of plugins to track # whether we have the latest version of everything. -VERSION=1.4.11 -HASH=3877f0e70f29e7d0612155632e48c3db1e626be3 +VERSION=1.5-rc +HASH=a7cb2a39702536d769c7ff93f716e27f0b93f9d9 PERSISTENT_LOGIN_VERSION=6b3fc450cae23ccb2f393d0ef67aa319e877e435 # version 5.2.0 HTML5_NOTIFIER_VERSION=68d9ca194212e15b3c7225eb6085dbcf02fd13d7 # version 0.6.4+ CARDDAV_VERSION=3.0.3 @@ -133,6 +133,7 @@ cat > $RCM_CONFIG <