Replace HMAC-based session API keys with tokens stored in memory in the daemon process

Since the session cache clears keys after a period of time, this fixes #1821.

Based on https://github.com/mail-in-a-box/mailinabox/pull/2012, and so:

Co-Authored-By: NewbieOrange <NewbieOrange@users.noreply.github.com>

Also fixes #2029 by not revealing through the login failure error message whether a user exists or not.
This commit is contained in:
Joshua Tauberer 2021-08-22 16:07:16 -04:00
parent 53ec0f39cb
commit e884c4774f
7 changed files with 149 additions and 103 deletions

View File

@ -54,24 +54,24 @@ tags:
System operations, which include system status checks, new version checks System operations, which include system status checks, new version checks
and reboot status. and reboot status.
paths: paths:
/me: /login:
get: post:
tags: tags:
- User - User
summary: Get user information summary: Exchange a username and password for a session API key.
description: | 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 Authenticate a user by supplying the auth token as a base64 encoded string in
format `email:password` using basic authentication headers. format `email:password` using basic authentication headers.
If successful, a long-lived `api_key` is returned which can be used for subsequent If successful, a long-lived `api_key` is returned which can be used for subsequent
requests to the API. requests to the API in place of the password.
operationId: getMe operationId: login
x-codeSamples: x-codeSamples:
- lang: curl - lang: curl
source: | source: |
curl -X GET "https://{host}/admin/me" \ curl -X GET "https://{host}/admin/login" \
-u "<email>:<password>" -u "<email>:<password>"
responses: responses:
200: 200:
@ -92,6 +92,24 @@ paths:
privileges: privileges:
- admin - admin
status: ok 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 "<email>:<session_key>"
responses:
200:
description: Successful operation
content:
application/json:
/system/status: /system/status:
post: post:
tags: tags:
@ -1803,7 +1821,7 @@ components:
The `access-token` is comprised of the Base64 encoding of `username:password`. 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 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. When using `curl`, you can supply user credentials using the `-u` or `--user` parameter.
requestBodies: requestBodies:

View File

@ -1,5 +1,7 @@
import base64, os, os.path, hmac, json, secrets import base64, os, os.path, hmac, json, secrets
from datetime import timedelta
from expiringdict import ExpiringDict
import utils import utils
from mailconfig import get_mail_password, get_mail_user_privileges 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' DEFAULT_AUTH_REALM = 'Mail-in-a-Box Management Server'
class AuthService: 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): def __init__(self):
self.auth_realm = DEFAULT_AUTH_REALM self.auth_realm = DEFAULT_AUTH_REALM
self.key_path = DEFAULT_KEY_PATH self.key_path = DEFAULT_KEY_PATH
self.max_session_duration = timedelta(days=2)
self.init_system_api_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): def init_system_api_key(self):
"""Write an API key to a local file so local processes can use the API""" """Write an API key to a local file so local processes can use the API"""
@ -31,123 +30,133 @@ class AuthService:
finally: finally:
os.umask(old_umask) 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) os.makedirs(os.path.dirname(self.key_path), exist_ok=True)
with create_file_with_mode(self.key_path, 0o640) as key_file: with create_file_with_mode(self.key_path, 0o640) as key_file:
key_file.write(self.key + '\n') key_file.write(self.key + '\n')
def authenticate(self, request, env): def authenticate(self, request, env, login_only=False, logout=False):
"""Test if the client key passed in HTTP Authorization header matches the service key """Test if the HTTP Authorization header's username matches the system key, a session key,
or if the or username/password passed in the header matches an administrator user. 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. 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. ('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): def parse_http_authorization_basic(header):
return base64.b64decode(s.encode('ascii')).decode('ascii') def decode(s):
return base64.b64decode(s.encode('ascii')).decode('ascii')
def parse_basic_auth(header):
if " " not in header: if " " not in header:
return None, None return None, None
scheme, credentials = header.split(maxsplit=1) scheme, credentials = header.split(maxsplit=1)
if scheme != 'Basic': if scheme != 'Basic':
return None, None return None, None
credentials = decode(credentials) credentials = decode(credentials)
if ":" not in credentials: if ":" not in credentials:
return None, None return None, None
username, password = credentials.split(':', maxsplit=1) username, password = credentials.split(':', maxsplit=1)
return username, password return username, password
header = request.headers.get('Authorization') username, password = parse_http_authorization_basic(request.headers.get('Authorization', ''))
if not header:
raise ValueError("No authorization header provided.")
username, password = parse_basic_auth(header)
if username in (None, ""): if username in (None, ""):
raise ValueError("Authorization header invalid.") raise ValueError("Authorization header invalid.")
if username == self.key: if username.strip() == "" and password.strip() == "":
# The user passed the system API key which grants administrative privs. 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"]) 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: else:
# The user is trying to log in with a username and either a password # The user is trying to log in with a username and a password
# (and possibly a MFA token) or a user-specific API key. # (and possibly a MFA token). On failure, an exception is raised.
return (username, self.check_user_auth(username, password, request, env)) 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): def check_user_auth(self, email, pw, request, env):
# Validate a user's login email address and password. If MFA is enabled, # Validate a user's login email address and password. If MFA is enabled,
# check the MFA token in the X-Auth-Token header. # check the MFA token in the X-Auth-Token header.
# #
# On success returns a list of privileges (e.g. [] or ['admin']). On login # On login failure, raises a ValueError with a login error message. On
# failure, raises a ValueError with a login error message. # success, nothing is returned.
# Sanity check. # Authenticate.
if email == "" or pw == "": try:
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:
# 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. 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) pw_hash = get_mail_password(email, env)
# Authenticate. # Use 'doveadm pw' to check credentials. doveadm will return
try: # a non-zero exit status if the credentials are no good,
# Use 'doveadm pw' to check credentials. doveadm will return # and check_call will raise an exception in that case.
# a non-zero exit status if the credentials are no good, utils.shell('check_call', [
# and check_call will raise an exception in that case. "/usr/bin/doveadm", "pw",
utils.shell('check_call', [ "-p", pw,
"/usr/bin/doveadm", "pw", "-t", pw_hash,
"-p", pw, ])
"-t", pw_hash, except:
]) # Login failed.
except: raise ValueError("Incorrect email address or password.")
# Login failed.
raise ValueError("Invalid password.")
# If MFA is enabled, check that MFA passes. # If MFA is enabled, check that MFA passes.
status, hints = validate_auth_mfa(email, request, env) status, hints = validate_auth_mfa(email, request, env)
if not status: if not status:
# Login valid. Hints may have more info. # Login valid. Hints may have more info.
raise ValueError(",".join(hints)) raise ValueError(",".join(hints))
# Get privileges for authorization. This call should never fail because by this def create_user_password_state_token(self, email, env):
# point we know the email address is a valid user. But on error the call will # Create a token that changes if the user's password or MFA options change
# return a tuple of an error message and an HTTP status code. # so that sessions become invalid if any of that information changes.
privs = get_mail_user_privileges(email, env) msg = get_mail_password(email, env).encode("utf8")
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")
# Add to the message the current MFA state, which is a list of MFA information. # Add to the message the current MFA state, which is a list of MFA information.
# Turn it into a string stably. # Turn it into a string stably.
msg += b" " + json.dumps(get_hash_mfa_state(email, env), sort_keys=True).encode("utf8") 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') hash_key = self.key.encode('ascii')
return hmac.new(hash_key, msg, digestmod="sha256").hexdigest() 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

View File

@ -56,8 +56,10 @@ def authorized_personnel_only(viewfunc):
try: try:
email, privs = auth_service.authenticate(request, env) email, privs = auth_service.authenticate(request, env)
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, unless no authorization header
log_failed_login(request) # was given which can happen on an initial request before a 403 response.
if "Authorization" in request.headers:
log_failed_login(request)
# Authentication failed. # Authentication failed.
error = str(e) error = str(e)
@ -134,11 +136,12 @@ def index():
csr_country_codes=csr_country_codes, csr_country_codes=csr_country_codes,
) )
@app.route('/me') # Create a session key by checking the username/password in the Authorization header.
def me(): @app.route('/login', methods=["POST"])
def login():
# Is the caller authorized? # Is the caller authorized?
try: try:
email, privs = auth_service.authenticate(request, env) email, privs = auth_service.authenticate(request, env, login_only=True)
except ValueError as e: except ValueError as e:
if "missing-totp-token" in str(e): if "missing-totp-token" in str(e):
return json_response({ return json_response({
@ -153,19 +156,29 @@ def me():
"reason": str(e), "reason": str(e),
}) })
# Return a new session for the user.
resp = { resp = {
"status": "ok", "status": "ok",
"email": email, "email": email,
"privileges": privs, "privileges": privs,
"api_key": auth_service.create_session_key(email, env, type='login'),
} }
# Is authorized as admin? Return an API key for future use. app.logger.info("New login session created for {}".format(email))
if "admin" in privs:
resp["api_key"] = auth_service.create_user_key(email, env)
# Return. # Return.
return json_response(resp) 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 # MAIL
@app.route('/mail/users') @app.route('/mail/users')

View File

@ -367,11 +367,17 @@ var current_panel = null;
var switch_back_to_panel = null; var switch_back_to_panel = null;
function do_logout() { function do_logout() {
// Clear the session from the backend.
api("/logout", "POST");
// Forget the token.
api_credentials = ["", ""]; api_credentials = ["", ""];
if (typeof localStorage != 'undefined') if (typeof localStorage != 'undefined')
localStorage.removeItem("miab-cp-credentials"); localStorage.removeItem("miab-cp-credentials");
if (typeof sessionStorage != 'undefined') if (typeof sessionStorage != 'undefined')
sessionStorage.removeItem("miab-cp-credentials"); sessionStorage.removeItem("miab-cp-credentials");
// Return to the start.
show_panel('login'); show_panel('login');
} }

View File

@ -105,8 +105,8 @@ function do_login() {
api_credentials = [$('#loginEmail').val(), $('#loginPassword').val()] api_credentials = [$('#loginEmail').val(), $('#loginPassword').val()]
api( api(
"/me", "/login",
"GET", "POST",
{}, {},
function(response) { function(response) {
// This API call always succeeds. It returns a JSON object indicating // This API call always succeeds. It returns a JSON object indicating

View File

@ -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. # NOTE: email_validator is repeated in setup/questions.sh, so please keep the versions synced.
hide_output $venv/bin/pip install --upgrade \ hide_output $venv/bin/pip install --upgrade \
rtyaml "email_validator>=1.0.0" "exclusiveprocess" \ rtyaml "email_validator>=1.0.0" "exclusiveprocess" \
flask dnspython python-dateutil \ flask dnspython python-dateutil expiringdict \
qrcode[pil] pyotp \ qrcode[pil] pyotp \
"idna>=2.0.0" "cryptography==2.2.2" boto psutil postfix-mta-sts-resolver b2sdk "idna>=2.0.0" "cryptography==2.2.2" boto psutil postfix-mta-sts-resolver b2sdk
# CONFIGURATION # CONFIGURATION

View File

@ -232,7 +232,7 @@ if __name__ == "__main__":
run_test(managesieve_test, [], 20, 30, 4) run_test(managesieve_test, [], 20, 30, 4)
# Mail-in-a-Box control panel # 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 # Munin via the Mail-in-a-Box control panel
run_test(http_test, ["/admin/munin/", 401], 20, 30, 1) run_test(http_test, ["/admin/munin/", 401], 20, 30, 1)