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 de2b61b5..38c15e91 100644 --- a/management/auth.py +++ b/management/auth.py @@ -1,5 +1,7 @@ import base64, os, os.path, hmac, json, secrets +from datetime import timedelta +from expiringdict import ExpiringDict import utils from mailconfig import get_mail_password, get_mail_user_privileges @@ -9,16 +11,13 @@ DEFAULT_KEY_PATH = '/var/lib/mailinabox/api.key' DEFAULT_AUTH_REALM = 'Mail-in-a-Box Management Server' class AuthService: - """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. - """ def __init__(self): self.auth_realm = DEFAULT_AUTH_REALM self.key_path = DEFAULT_KEY_PATH + self.max_session_duration = timedelta(days=2) + 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""" @@ -31,123 +30,133 @@ class AuthService: finally: os.umask(old_umask) - self.key = secrets.token_hex(24) + 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.") - if username == self.key: - # The user passed the system 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.") - - # 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: + # Authenticate. + try: # 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. But wrap it in the + # same exception as if a password fails so we don't easily reveal + # if an email address is valid. pw_hash = get_mail_password(email, env) - # Authenticate. - try: - # 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", "pw", - "-p", pw, - "-t", pw_hash, - ]) - except: - # Login failed. - raise ValueError("Invalid password.") + # 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", "pw", + "-p", pw, + "-t", pw_hash, + ]) + except: + # Login failed. + raise ValueError("Incorrect email address or 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 system API key as a key, - # which also means that the API key becomes invalid when our system 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") + 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 = 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 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 bb723ea6..ca891772 100755 --- a/management/daemon.py +++ b/management/daemon.py @@ -56,8 +56,10 @@ def authorized_personnel_only(viewfunc): try: email, privs = auth_service.authenticate(request, env) except ValueError as e: - # Write a line in the log recording the failed login - log_failed_login(request) + # Write a line in the log recording the failed login, unless no authorization header + # was given which can happen on an initial request before a 403 response. + if "Authorization" in request.headers: + log_failed_login(request) # Authentication failed. error = str(e) @@ -134,11 +136,12 @@ def index(): csr_country_codes=csr_country_codes, ) -@app.route('/me') -def me(): +# Create a session key by checking the username/password in the Authorization header. +@app.route('/login', methods=["POST"]) +def login(): # Is the caller authorized? try: - email, privs = auth_service.authenticate(request, env) + email, privs = auth_service.authenticate(request, env, login_only=True) except ValueError as e: if "missing-totp-token" in str(e): return json_response({ @@ -153,19 +156,29 @@ def me(): "reason": str(e), }) + # Return a new session for the user. resp = { "status": "ok", "email": email, "privileges": privs, + "api_key": auth_service.create_session_key(email, env, type='login'), } - # Is authorized as admin? Return an API key for future use. - if "admin" in privs: - resp["api_key"] = auth_service.create_user_key(email, env) + app.logger.info("New login session created for {}".format(email)) # Return. return json_response(resp) +@app.route('/logout', methods=["POST"]) +def logout(): + try: + email, _ = auth_service.authenticate(request, env, logout=True) + app.logger.info("{} logged out".format(email)) + except ValueError as e: + pass + finally: + return json_response({ "status": "ok" }) + # MAIL @app.route('/mail/users') diff --git a/management/templates/index.html b/management/templates/index.html index 12f6ad8e..267f5dd6 100644 --- a/management/templates/index.html +++ b/management/templates/index.html @@ -367,11 +367,17 @@ var current_panel = null; var switch_back_to_panel = null; function do_logout() { + // Clear the session from the backend. + api("/logout", "POST"); + + // Forget the token. api_credentials = ["", ""]; 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'); } diff --git a/management/templates/login.html b/management/templates/login.html index 19b23d3a..3447d794 100644 --- a/management/templates/login.html +++ b/management/templates/login.html @@ -105,8 +105,8 @@ function do_login() { api_credentials = [$('#loginEmail').val(), $('#loginPassword').val()] api( - "/me", - "GET", + "/login", + "POST", {}, function(response) { // This API call always succeeds. It returns a JSON object indicating diff --git a/setup/management.sh b/setup/management.sh index 1c57bb2e..7e31fe00 100755 --- a/setup/management.sh +++ b/setup/management.sh @@ -49,8 +49,8 @@ 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 \ - qrcode[pil] pyotp \ + flask dnspython python-dateutil expiringdict \ + qrcode[pil] pyotp \ "idna>=2.0.0" "cryptography==2.2.2" boto psutil postfix-mta-sts-resolver b2sdk # CONFIGURATION diff --git a/tests/fail2ban.py b/tests/fail2ban.py index 1cb55eba..cb55c51f 100644 --- a/tests/fail2ban.py +++ b/tests/fail2ban.py @@ -232,7 +232,7 @@ if __name__ == "__main__": run_test(managesieve_test, [], 20, 30, 4) # Mail-in-a-Box control panel - run_test(http_test, ["/admin/me", 200], 20, 30, 1) + run_test(http_test, ["/admin/login", 200], 20, 30, 1) # Munin via the Mail-in-a-Box control panel run_test(http_test, ["/admin/munin/", 401], 20, 30, 1)