From 067052d4ea7ce0e349ec3670d0f76a9bd319571f Mon Sep 17 00:00:00 2001 From: Michael Kropat Date: Sat, 21 Jun 2014 23:42:48 +0000 Subject: [PATCH 1/6] Add key-based authentication to management service Intended to be the simplest auth possible: every time the service starts, a random key is written to `/var/lib/mailinabox/api.key`. In order to authenticate to the service, the client must pass the contents of `api.key` in an HTTP basic auth header. In this way, users who do not have read access to that file are not able to communicate with the service. --- management/auth.py | 69 ++++++++++++++++++++++++++++++++++++++++++++ management/daemon.py | 26 +++++++++++++++-- setup/start.sh | 1 + 3 files changed, 94 insertions(+), 2 deletions(-) create mode 100644 management/auth.py diff --git a/management/auth.py b/management/auth.py new file mode 100644 index 0000000..4fde5e9 --- /dev/null +++ b/management/auth.py @@ -0,0 +1,69 @@ +import base64, os, os.path + +from flask import make_response + +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. + """ + def __init__(self, env): + self.auth_realm = DEFAULT_AUTH_REALM + self.key = self._generate_key() + self.key_path = env.get('API_KEY_FILE') or DEFAULT_KEY_PATH + + def write_key(self): + """Write key to file so authorized clients can get the key + + 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) + try: + return os.fdopen(os.open(path, os.O_WRONLY | os.O_CREAT, mode), 'w') + finally: + os.umask(old_umask) + + 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 is_authenticated(self, request): + """Test if the client key passed in HTTP header matches the service key""" + + def decode(s): + return base64.b64decode(s.encode('utf-8')).decode('ascii') + + def parse_api_key(header): + if header is None: + return + + scheme, credentials = header.split(maxsplit=1) + if scheme != 'Basic': + return + + username, password = decode(credentials).split(':', maxsplit=1) + return username + + request_key = parse_api_key(request.headers.get('Authorization')) + + return request_key == self.key + + def make_unauthorized_response(self): + return make_response( + 'You must pass the API key from "{0}" as the username\n'.format(self.key_path), + 401, + { 'WWW-Authenticate': 'Basic realm="{0}"'.format(self.auth_realm) }) + + def _generate_key(self): + raw_key = os.urandom(32) + return base64.b64encode(raw_key).decode('ascii') diff --git a/management/daemon.py b/management/daemon.py index 9d652dc..f7053c1 100755 --- a/management/daemon.py +++ b/management/daemon.py @@ -2,14 +2,25 @@ import os, os.path, re -from flask import Flask, request, render_template +from flask import Flask, request, render_template, abort app = Flask(__name__) -import utils +import auth, utils from mailconfig import get_mail_users, add_mail_user, set_mail_password, remove_mail_user, get_mail_aliases, get_mail_domains, add_mail_alias, remove_mail_alias env = utils.load_environment() +auth_service = auth.KeyAuthService(env) + +@app.before_request +def require_auth_key(): + if not auth_service.is_authenticated(request): + abort(401) + +@app.errorhandler(401) +def unauthorized(error): + return auth_service.make_unauthorized_response() + @app.route('/') def index(): return render_template('index.html') @@ -97,4 +108,15 @@ def do_updates(): if __name__ == '__main__': if "DEBUG" in os.environ: app.debug = True + + # For testing on the command line, you can use `curl` like so: + # curl --user $( Date: Sat, 21 Jun 2014 23:49:09 +0000 Subject: [PATCH 2/6] Update mail tool to pass api key auth --- tools/mail.py | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/tools/mail.py b/tools/mail.py index 688fac4..0b4e449 100755 --- a/tools/mail.py +++ b/tools/mail.py @@ -3,7 +3,11 @@ import sys, getpass, urllib.request, urllib.error def mgmt(cmd, data=None): - req = urllib.request.Request('http://localhost:10222' + cmd, urllib.parse.urlencode(data).encode("utf8") if data else None) + mgmt_uri = 'http://localhost:10222' + + setup_key_auth(mgmt_uri) + + req = urllib.request.Request(mgmt_uri + cmd, urllib.parse.urlencode(data).encode("utf8") if data else None) try: response = urllib.request.urlopen(req) except urllib.error.HTTPError as e: @@ -20,6 +24,18 @@ def read_password(): second = getpass.getpass(' (again): ') return first +def setup_key_auth(mgmt_uri): + key = open('/var/lib/mailinabox/api.key').read().strip() + + auth_handler = urllib.request.HTTPBasicAuthHandler() + auth_handler.add_password( + realm='Mail-in-a-Box Management Server', + uri=mgmt_uri, + user=key, + passwd='') + opener = urllib.request.build_opener(auth_handler) + urllib.request.install_opener(opener) + if len(sys.argv) < 2: print("Usage: ") print(" tools/mail.py user (lists users)") From 88e496eba4da5e369302a9b7510a7f62cea7e38b Mon Sep 17 00:00:00 2001 From: Michael Kropat Date: Sun, 22 Jun 2014 00:02:52 +0000 Subject: [PATCH 3/6] Update setup scripts to auth against the API --- setup/dns.sh | 2 +- setup/start.sh | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/setup/dns.sh b/setup/dns.sh index 770e160..55528a0 100644 --- a/setup/dns.sh +++ b/setup/dns.sh @@ -68,7 +68,7 @@ cat > /etc/cron.daily/mailinabox-dnssec << EOF; #!/bin/bash # Mail-in-a-Box # Re-sign any DNS zones with DNSSEC because the signatures expire periodically. -curl -d GO http://localhost:10222/dns/update +curl -d GO --user \$( Date: Sun, 22 Jun 2014 00:07:14 +0000 Subject: [PATCH 4/6] Update documentation to use API auth The updated instruction is not very user-friendly. I think the right solution is to wrap the `/dns` commands in a `tools/dns.py` style script, along the lines of `tools/mail.py`. --- docs/index.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/index.md b/docs/index.md index b9ec905..f696917 100644 --- a/docs/index.md +++ b/docs/index.md @@ -58,7 +58,7 @@ For instance, in my case, I could tell my domain name registrar that `ns1.box.oc Optionally, to activate DNSSEC, you'll need to get a DS record from the box. While logged in on the box, run: - curl http://localhost:10222/dns/ds + sudo bash -c 'curl --user $( Date: Sun, 22 Jun 2014 08:45:29 -0400 Subject: [PATCH 5/6] Remove API_KEY_FILE setting --- management/auth.py | 2 +- setup/start.sh | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/management/auth.py b/management/auth.py index 4fde5e9..776d39b 100644 --- a/management/auth.py +++ b/management/auth.py @@ -15,7 +15,7 @@ class KeyAuthService: def __init__(self, env): self.auth_realm = DEFAULT_AUTH_REALM self.key = self._generate_key() - self.key_path = env.get('API_KEY_FILE') or DEFAULT_KEY_PATH + self.key_path = DEFAULT_KEY_PATH def write_key(self): """Write key to file so authorized clients can get the key diff --git a/setup/start.sh b/setup/start.sh index f8e5e9c..fd55daf 100755 --- a/setup/start.sh +++ b/setup/start.sh @@ -118,7 +118,6 @@ PUBLIC_HOSTNAME=$PUBLIC_HOSTNAME PUBLIC_IP=$PUBLIC_IP PUBLIC_IPV6=$PUBLIC_IPV6 CSR_COUNTRY=$CSR_COUNTRY -API_KEY_FILE=/var/lib/mailinabox/api.key EOF # Start service configuration. From 9e63ec62fb219cf9f7597fc39ca9d0c2b36d2e76 Mon Sep 17 00:00:00 2001 From: Michael Kropat Date: Sun, 22 Jun 2014 08:55:19 -0400 Subject: [PATCH 6/6] Cleanup: remove env dependency --- management/auth.py | 2 +- management/daemon.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/management/auth.py b/management/auth.py index 776d39b..80c9fcb 100644 --- a/management/auth.py +++ b/management/auth.py @@ -12,7 +12,7 @@ class KeyAuthService: requests. The key is passed as the username field in the standard HTTP Basic Auth header. """ - def __init__(self, env): + def __init__(self): self.auth_realm = DEFAULT_AUTH_REALM self.key = self._generate_key() self.key_path = DEFAULT_KEY_PATH diff --git a/management/daemon.py b/management/daemon.py index f21380a..4b30256 100755 --- a/management/daemon.py +++ b/management/daemon.py @@ -10,7 +10,7 @@ from mailconfig import get_mail_users, add_mail_user, set_mail_password, remove_ env = utils.load_environment() -auth_service = auth.KeyAuthService(env) +auth_service = auth.KeyAuthService() @app.before_request def require_auth_key():