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:
parent
53ec0f39cb
commit
e884c4774f
7 changed files with 149 additions and 103 deletions
|
@ -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:
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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')
|
||||||
|
|
|
@ -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');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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)
|
||||||
|
|
Loading…
Reference in a new issue