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.
This commit is contained in:
Michael Kropat 2014-06-21 23:42:48 +00:00
parent 326cc2a451
commit 067052d4ea
3 changed files with 94 additions and 2 deletions

69
management/auth.py Normal file
View File

@ -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')

View File

@ -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 $(</var/lib/mailinabox/api.key): http://localhost:10222/mail/users
auth_service.write_key()
# For testing in the browser, you can copy the API key that's output to the
# debug console and enter that as the username
app.logger.info('API key: ' + auth_service.key)
app.run(port=10222)

View File

@ -118,6 +118,7 @@ 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.