diff --git a/.travis.yml b/.travis.yml index 8ca14149..47a34c19 100644 --- a/.travis.yml +++ b/.travis.yml @@ -44,8 +44,8 @@ jobs: - UPSTREAM_TAG=master name: upgrade-from-upstream install: - - sudo tests/system-setup/upgrade-from-upstream.sh basic + - sudo tests/system-setup/upgrade-from-upstream.sh basic totpuser script: # launch automated tests, but skip tests that require remote # smtp support because Travis-CI blocks outgoing port 25 - - sudo tests/runner.sh -dumpoutput -no-smtp-remote default upgrade-basic + - sudo tests/runner.sh -dumpoutput -no-smtp-remote upgrade-basic upgrade-totpuser default diff --git a/api/mailinabox.yml b/api/mailinabox.yml index 57ba5aa4..a9a2c124 100644 --- a/api/mailinabox.yml +++ b/api/mailinabox.yml @@ -8,7 +8,7 @@ info: This API is documented in [**OpenAPI format**](http://spec.openapis.org/oas/v3.0.3). ([View the full HTTP specification](https://raw.githubusercontent.com/mail-in-a-box/mailinabox/api-spec/api/mailinabox.yml).) - All endpoints are relative to `https://{host}/admin` and are secured with [`Basic Access` authentication](https://en.wikipedia.org/wiki/Basic_access_authentication). + All endpoints are relative to `https://{host}/admin` and are secured with [`Basic Access` authentication](https://en.wikipedia.org/wiki/Basic_access_authentication). If you have multi-factor authentication enabled, authentication with a `user:password` combination will fail unless a valid OTP is supplied via the `x-auth-token` header. Authentication via a `user:user_key` pair is possible without the header being present. contact: name: Mail-in-a-Box support url: https://mailinabox.email/ @@ -46,6 +46,9 @@ tags: - name: Web description: | Static web hosting operations, which include getting domain information and updating domain root directories. + - name: MFA + description: | + Manage multi-factor authentication schemes. Currently, only TOTP is supported. - name: System description: | System operations, which include system status checks, new version checks @@ -1662,6 +1665,101 @@ paths: text/html: schema: type: string + /mfa/status: + post: + tags: + - MFA + summary: Retrieve MFA status for you or another user + description: Retrieves which type of MFA is used and configuration + operationId: mfaStatus + x-codeSamples: + - lang: curl + source: | + curl -X POST "https://{host}/admin/mfa/status" \ + -u ":" + responses: + 200: + description: Successful operation + content: + application/json: + schema: + $ref: '#/components/schemas/MfaStatusResponse' + 403: + description: Forbidden + content: + text/html: + schema: + type: string + /mfa/totp/enable: + post: + tags: + - MFA + summary: Enable TOTP authentication + description: Enables TOTP authentication for the currently logged-in admin user + operationId: mfaTotpEnable + x-codeSamples: + - lang: curl + source: | + curl -X POST "https://{host}/admin/mfa/totp/enable" \ + -d "code=123456" \ + -d "secret=" \ + -u ":" + requestBody: + required: true + content: + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/MfaEnableRequest' + responses: + 200: + description: Successful operation + content: + text/html: + schema: + $ref: '#/components/schemas/MfaEnableSuccessResponse' + 400: + description: Bad request + content: + text/html: + schema: + type: string + 403: + description: Forbidden + content: + text/html: + schema: + type: string + /mfa/disable: + post: + tags: + - MFA + summary: Disable multi-factor authentication for you or another user + description: Disables multi-factor authentication for the currently logged-in admin user or another user if a 'user' parameter is submitted. Either disables all multi-factor authentication methods or the method corresponding to the optional property `mfa_id`. + operationId: mfaTotpDisable + requestBody: + required: false + content: + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/MfaDisableRequest' + x-codeSamples: + - lang: curl + source: | + curl -X POST "https://{host}/admin/mfa/totp/disable" \ + -u ":" + responses: + 200: + description: Successful operation + content: + text/html: + schema: + $ref: '#/components/schemas/MfaDisableSuccessResponse' + 403: + description: Forbidden + content: + text/html: + schema: + type: string components: securitySchemes: basicAuth: @@ -2529,3 +2627,54 @@ components: type: string example: web updated description: Web update response. + MfaStatusResponse: + type: object + properties: + enabled_mfa: + type: object + properties: + id: + type: string + type: + type: string + label: + type: string + nullable: true + new_mfa: + type: object + properties: + type: + type: string + secret: + type: string + qr_code_base64: + type: string + MfaEnableRequest: + type: object + required: + - secret + - code + properties: + secret: + type: string + code: + type: string + label: + type: string + MfaEnableSuccessResponse: + type: string + MfaEnableBadRequestResponse: + type: object + required: + - error + properties: + error: + type: string + MfaDisableRequest: + type: object + properties: + mfa_id: + type: string + nullable: true + MfaDisableSuccessResponse: + type: string diff --git a/conf/mfa-totp.schema b/conf/mfa-totp.schema new file mode 100644 index 00000000..89f65dca --- /dev/null +++ b/conf/mfa-totp.schema @@ -0,0 +1,65 @@ +# +# MiaB-LDAP's directory schema for time-based one time passwords (TOTP) +# +# MiaB LDAP UUID(v4): 7392cdda-5ec8-431f-9936-0000273c0167 +# or: 1939000794.24264.17183.39222.658243943 +# + +objectIdentifier MiabLDAProot 2.25.1939000794.24264.17183.39222.658243943 + +objectIdentifier MiabLDAPmfa MiabLDAProot:1 +objectIdentifier MiabLDAPmfaAttributeType MiabLDAPmfa:2 +objectIdentifier MiabLDAPmfaObjectClass MiabLDAPmfa:3 + +# secret consists of base32 characters (see RFC 4648) + +attributetype ( MiabLDAPmfaAttributeType:1 + DESC 'TOTP secret' + NAME 'totpSecret' + SYNTAX 1.3.6.1.4.1.1466.115.121.1.26 + X-ORDERED 'VALUES' + EQUALITY caseExactIA5Match ) + + +# tokens are a base-10 string of N digits, but set the syntax to +# IA5String anyway + +attributetype ( MiabLDAPmfaAttributeType:2 + DESC 'TOTP last token used' + NAME 'totpMruToken' + SYNTAX 1.3.6.1.4.1.1466.115.121.1.26 + X-ORDERED 'VALUES' + EQUALITY caseExactIA5Match ) + +# the time in nanoseconds since the epoch when the mru token was last +# used. the time will also be set when a new entry is created even if +# the corresponding mru token is blank + +attributetype ( MiabLDAPmfaAttributeType:3 + DESC 'TOTP last token used time' + NAME 'totpMruTokenTime' + SYNTAX 1.3.6.1.4.1.1466.115.121.1.26 + X-ORDERED 'VALUES' + EQUALITY caseExactIA5Match ) + +# The label is currently any text supplied by the user, which is used +# as a reminder of where the secret is stored when logging in (where +# the authenticator app is, that holds the secret). eg "my samsung +# phone" + +attributetype ( MiabLDAPmfaAttributeType:4 + DESC 'TOTP device label' + NAME 'totpLabel' + SYNTAX 1.3.6.1.4.1.1466.115.121.1.26 + X-ORDERED 'VALUES' + EQUALITY caseIgnoreIA5Match ) + + +# The TOTP objectClass + +objectClass ( MiabLDAPmfaObjectClass:1 + NAME 'totpUser' + DESC 'MiaB-LDAP TOTP settings for a user' + SUP top + AUXILIARY + MUST ( totpSecret $ totpMruToken $ totpMruTokenTime $ totpLabel ) ) diff --git a/ehdd/startup.sh b/ehdd/startup.sh index 73ce06aa..ad4bd6dd 100755 --- a/ehdd/startup.sh +++ b/ehdd/startup.sh @@ -10,7 +10,7 @@ if [ -s /etc/mailinabox.conf ]; then systemctl start cron #systemctl start nsd systemctl link -f $(pwd)/conf/mailinabox.service - systemctl start mailinabox systemctl start fail2ban + systemctl restart mailinabox fi diff --git a/management/auth.py b/management/auth.py index 3cb4432d..46768684 100644 --- a/management/auth.py +++ b/management/auth.py @@ -1,9 +1,10 @@ -import base64, os, os.path, hmac +import base64, os, os.path, hmac, json from flask import make_response import utils from mailconfig import validate_login, get_mail_password, get_mail_user_privileges +from mfa import get_hash_mfa_state, validate_auth_mfa DEFAULT_KEY_PATH = '/var/lib/mailinabox/api.key' DEFAULT_AUTH_REALM = 'Mail-in-a-Box Management Server' @@ -72,17 +73,19 @@ class KeyAuthService: if username in (None, ""): raise ValueError("Authorization header invalid.") elif username == self.key: - # The user passed the API key which grants administrative privs. + # The user passed the master API key which grants administrative privs. return (None, ["admin"]) else: - # The user is trying to log in with a username and user-specific - # API key or password. Raises or returns privs. - return (username, self.get_user_credentials(username, password, env)) + # 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)) - def get_user_credentials(self, email, pw, env): - # Validate a user's credentials. On success returns a list of - # privileges (e.g. [] or ['admin']). On failure raises a ValueError - # with a login error message. + 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. # Sanity check. if email == "" or pw == "": @@ -100,6 +103,12 @@ class KeyAuthService: # Login failed. raise ValueError("Invalid 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)) + # 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. @@ -110,16 +119,27 @@ class KeyAuthService: return privs def create_user_key(self, email, env): - # Store an HMAC with the client. The hashed message of the HMAC will be the user's - # email address & hashed password and the key will be the master API key. The user of - # course has their own email address and password. We assume they do not have the master - # API key (unless they are trusted anyway). The HMAC proves that they authenticated - # with us in some other way to get the HMAC. Including the password means that when - # a user's password is reset, the HMAC changes and they will correctly need to log - # in to the control panel again. This method raises a ValueError if the user does - # not exist, due to get_mail_password. + # 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 master API key as a key, + # which also means that the API key becomes invalid when our master 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" " + ";".join(get_mail_password(email, env)).encode("utf8") - return hmac.new(self.key.encode('ascii'), msg, digestmod="sha256").hexdigest() + + # 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. + hash_key = self.key.encode('ascii') + return hmac.new(hash_key, msg, digestmod="sha256").hexdigest() def _generate_key(self): raw_key = os.urandom(32) diff --git a/management/backend.py b/management/backend.py index 363fe491..05edd73c 100644 --- a/management/backend.py +++ b/management/backend.py @@ -192,7 +192,7 @@ class LdapConnection(ldap3.Connection): # have values for each attribute in `attrs_to_update` # attrs_to_update: an array of attribute names to update # objectClasses: a list of object classes for a new entry - # values: a dict of attributes and values for a new entry + # values: a dict of attributes and values for a new or modified entry if existing_record: # modify existing changes = {} diff --git a/management/cli.py b/management/cli.py new file mode 100755 index 00000000..1b91b003 --- /dev/null +++ b/management/cli.py @@ -0,0 +1,150 @@ +#!/usr/bin/python3 +# +# This is a command-line script for calling management APIs +# on the Mail-in-a-Box control panel backend. The script +# reads /var/lib/mailinabox/api.key for the backend's +# root API key. This file is readable only by root, so this +# tool can only be used as root. + +import sys, getpass, urllib.request, urllib.error, json, re, csv + +def mgmt(cmd, data=None, is_json=False): + # The base URL for the management daemon. (Listens on IPv4 only.) + mgmt_uri = 'http://127.0.0.1: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: + if e.code == 401: + try: + print(e.read().decode("utf8")) + except: + pass + print("The management daemon refused access. The API key file may be out of sync. Try 'service mailinabox restart'.", file=sys.stderr) + elif hasattr(e, 'read'): + print(e.read().decode('utf8'), file=sys.stderr) + else: + print(e, file=sys.stderr) + sys.exit(1) + resp = response.read().decode('utf8') + if is_json: resp = json.loads(resp) + return resp + +def read_password(): + while True: + first = getpass.getpass('password: ') + if len(first) < 8: + print("Passwords must be at least eight characters.") + continue + second = getpass.getpass(' (again): ') + if first != second: + print("Passwords not the same. Try again.") + continue + break + 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: + {cli} user (lists users) + {cli} user add user@domain.com [password] + {cli} user password user@domain.com [password] + {cli} user remove user@domain.com + {cli} user make-admin user@domain.com + {cli} user remove-admin user@domain.com + {cli} user admins (lists admins) + {cli} user mfa show user@domain.com (shows MFA devices for user, if any) + {cli} user mfa disable user@domain.com [id] (disables MFA for user) + {cli} alias (lists aliases) + {cli} alias add incoming.name@domain.com sent.to@other.domain.com + {cli} alias add incoming.name@domain.com 'sent.to@other.domain.com, multiple.people@other.domain.com' + {cli} alias remove incoming.name@domain.com + +Removing a mail user does not delete their mail folders on disk. It only prevents IMAP/SMTP login. +""".format( + cli="management/cli.py" + )) + +elif sys.argv[1] == "user" and len(sys.argv) == 2: + # Dump a list of users, one per line. Mark admins with an asterisk. + users = mgmt("/mail/users?format=json", is_json=True) + for domain in users: + for user in domain["users"]: + if user['status'] == 'inactive': continue + print(user['email'], end='') + if "admin" in user['privileges']: + print("*", end='') + print() + +elif sys.argv[1] == "user" and sys.argv[2] in ("add", "password"): + if len(sys.argv) < 5: + if len(sys.argv) < 4: + email = input("email: ") + else: + email = sys.argv[3] + pw = read_password() + else: + email, pw = sys.argv[3:5] + + if sys.argv[2] == "add": + print(mgmt("/mail/users/add", { "email": email, "password": pw })) + elif sys.argv[2] == "password": + print(mgmt("/mail/users/password", { "email": email, "password": pw })) + +elif sys.argv[1] == "user" and sys.argv[2] == "remove" and len(sys.argv) == 4: + print(mgmt("/mail/users/remove", { "email": sys.argv[3] })) + +elif sys.argv[1] == "user" and sys.argv[2] in ("make-admin", "remove-admin") and len(sys.argv) == 4: + if sys.argv[2] == "make-admin": + action = "add" + else: + action = "remove" + print(mgmt("/mail/users/privileges/" + action, { "email": sys.argv[3], "privilege": "admin" })) + +elif sys.argv[1] == "user" and sys.argv[2] == "admins": + # Dump a list of admin users. + users = mgmt("/mail/users?format=json", is_json=True) + for domain in users: + for user in domain["users"]: + if "admin" in user['privileges']: + print(user['email']) + +elif sys.argv[1] == "user" and len(sys.argv) == 5 and sys.argv[2:4] == ["mfa", "show"]: + # Show MFA status for a user. + status = mgmt("/mfa/status", { "user": sys.argv[4] }, is_json=True) + W = csv.writer(sys.stdout) + W.writerow(["id", "type", "label"]) + for mfa in status["enabled_mfa"]: + W.writerow([mfa["id"], mfa["type"], mfa["label"]]) + +elif sys.argv[1] == "user" and len(sys.argv) in (5, 6) and sys.argv[2:4] == ["mfa", "disable"]: + # Disable MFA (all or a particular device) for a user. + print(mgmt("/mfa/disable", { "user": sys.argv[4], "mfa-id": sys.argv[5] if len(sys.argv) == 6 else None })) + +elif sys.argv[1] == "alias" and len(sys.argv) == 2: + print(mgmt("/mail/aliases")) + +elif sys.argv[1] == "alias" and sys.argv[2] == "add" and len(sys.argv) == 5: + print(mgmt("/mail/aliases/add", { "address": sys.argv[3], "forwards_to": sys.argv[4] })) + +elif sys.argv[1] == "alias" and sys.argv[2] == "remove" and len(sys.argv) == 4: + print(mgmt("/mail/aliases/remove", { "address": sys.argv[3] })) + +else: + print("Invalid command-line arguments.") + sys.exit(1) + diff --git a/management/daemon.py b/management/daemon.py index 46c84778..827b7daa 100755 --- a/management/daemon.py +++ b/management/daemon.py @@ -1,14 +1,16 @@ import os, os.path, re, json, time -import subprocess +import multiprocessing.pool, subprocess from functools import wraps from flask import Flask, request, render_template, abort, Response, send_from_directory, make_response -import auth, utils, multiprocessing.pool +import auth, utils from mailconfig import get_mail_users, get_mail_users_ex, get_admins, add_mail_user, set_mail_password, set_mail_display_name, remove_mail_user from mailconfig import get_mail_user_privileges, add_remove_mail_user_privilege from mailconfig import get_mail_aliases, get_mail_aliases_ex, get_mail_domains, add_mail_alias, remove_mail_alias +from mfa import get_public_mfa_state, enable_mfa, disable_mfa +import mfa_totp env = utils.load_environment() @@ -35,23 +37,31 @@ app = Flask(__name__, template_folder=os.path.abspath(os.path.join(os.path.dirna def authorized_personnel_only(viewfunc): @wraps(viewfunc) def newview(*args, **kwargs): - # Authenticate the passed credentials, which is either the API key or a username:password pair. + # Authenticate the passed credentials, which is either the API key or a username:password pair + # and an optional X-Auth-Token token. error = None + privs = [] + try: email, privs = auth_service.authenticate(request, env) except ValueError as e: - # Authentication failed. - privs = [] - error = "Incorrect username or password" - # Write a line in the log recording the failed login log_failed_login(request) + # Authentication failed. + error = str(e) + # Authorized to access an API view? if "admin" in privs: + # Store the email address of the logged in user so it can be accessed + # from the API methods that affect the calling user. + request.user_email = email + request.user_privs = privs + # Call view func. return viewfunc(*args, **kwargs) - elif not error: + + if not error: error = "You are not an administrator." # Not authorized. Return a 401 (send auth) and a prompt to authorize by default. @@ -83,8 +93,8 @@ def authorized_personnel_only(viewfunc): def unauthorized(error): return auth_service.make_unauthorized_response() -def json_response(data): - return Response(json.dumps(data, indent=2, sort_keys=True)+'\n', status=200, mimetype='application/json') +def json_response(data, status=200): + return Response(json.dumps(data, indent=2, sort_keys=True)+'\n', status=status, mimetype='application/json') ################################### @@ -119,12 +129,17 @@ def me(): try: email, privs = auth_service.authenticate(request, env) except ValueError as e: - # Log the failed login - log_failed_login(request) - - return json_response({ - "status": "invalid", - "reason": "Incorrect username or password", + if "missing-totp-token" in str(e): + return json_response({ + "status": "missing-totp-token", + "reason": str(e), + }) + else: + # Log the failed login + log_failed_login(request) + return json_response({ + "status": "invalid", + "reason": str(e), }) resp = { @@ -343,7 +358,7 @@ def ssl_get_status(): # What domains can we provision certificates for? What unexpected problems do we have? provision, cant_provision = get_certificates_to_provision(env, show_valid_certs=False) - + # What's the current status of TLS certificates on all of the domain? domains_status = get_web_domains_info(env) domains_status = [ @@ -392,6 +407,60 @@ def ssl_provision_certs(): requests = provision_certificates(env, limit_domains=None) return json_response({ "requests": requests }) +# multi-factor auth + +@app.route('/mfa/status', methods=['POST']) +@authorized_personnel_only +def mfa_get_status(): + # Anyone accessing this route is an admin, and we permit them to + # see the MFA status for any user if they submit a 'user' form + # field. But we don't include provisioning info since a user can + # only provision for themselves. + email = request.form.get('user', request.user_email) # user field if given, otherwise the user making the request + try: + resp = { + "enabled_mfa": get_public_mfa_state(email, env) + } + if email == request.user_email: + resp.update({ + "new_mfa": { + "totp": mfa_totp.provision(email, env) + } + }) + except ValueError as e: + return (str(e), 400) + return json_response(resp) + +@app.route('/mfa/totp/enable', methods=['POST']) +@authorized_personnel_only +def totp_post_enable(): + secret = request.form.get('secret') + token = request.form.get('token') + label = request.form.get('label') + if type(token) != str: + return ("Bad Input", 400) + try: + mfa_totp.validate_secret(secret) + enable_mfa(request.user_email, "totp", secret, token, label, env) + except ValueError as e: + return (str(e), 400) + return "OK" + +@app.route('/mfa/disable', methods=['POST']) +@authorized_personnel_only +def totp_post_disable(): + # Anyone accessing this route is an admin, and we permit them to + # disable the MFA status for any user if they submit a 'user' form + # field. + email = request.form.get('user', request.user_email) # user field if given, otherwise the user making the request + try: + result = disable_mfa(email, request.form.get('mfa-id') or None, env) # convert empty string to None + except ValueError as e: + return (str(e), 400) + if result: # success + return "OK" + else: # error + return ("Invalid user or MFA id.", 400) # WEB diff --git a/management/mailconfig.py b/management/mailconfig.py index 078ec809..df32acac 100755 --- a/management/mailconfig.py +++ b/management/mailconfig.py @@ -1190,7 +1190,6 @@ def validate_password(pw): if len(pw) < 8: raise ValueError("Passwords must be at least eight characters.") - if __name__ == "__main__": import sys if len(sys.argv) > 2 and sys.argv[1] == "validate-email": diff --git a/management/mfa.py b/management/mfa.py new file mode 100644 index 00000000..9c3a6636 --- /dev/null +++ b/management/mfa.py @@ -0,0 +1,144 @@ +# -*- indent-tabs-mode: t; tab-width: 4; python-indent-offset: 4; -*- + +from mailconfig import open_database, find_mail_user +import mfa_totp + +def strip_order_prefix(rec, attributes): + '''strip the order prefix from X-ORDERED ldap values for the + list of attributes specified + + `rec` is modified in-place + + the server returns X-ORDERED values ordered so the values will be + sorted in the record making the prefix superfluous. + + For example, the function will change: + totpSecret: {0}secret1 + totpSecret: {1}secret2 + to: + totpSecret: secret1 + totpSecret: secret2 + + TODO: move to backend.py and/or integrate with LdapConnection.search() + ''' + for attr in attributes: + # ignore attribute that doesn't exist + if not attr in rec: continue + # ..as well as None values and empty list + if not rec[attr]: continue + + newvals = [] + for val in rec[attr]: + i = val.find('}') + newvals.append(val[i+1:]) + rec[attr] = newvals + +def get_mfa_user(email, env, conn=None): + '''get the ldap record for the user along with all MFA-related + attributes + + ''' + user = find_mail_user(env, email, ['objectClass','totpSecret','totpMruToken','totpMruTokenTime','totpLabel'], conn) + if not user: + raise ValueError("User does not exist.") + strip_order_prefix(user, ['totpSecret','totpMruToken','totpMruTokenTime','totpLabel']) + return user + + + +def get_mfa_state(email, env): + '''return details about what MFA schemes are enabled for a user + ordered by the priority that the scheme will be tried, with index + zero being the first. + + ''' + user = get_mfa_user(email, env) + state_list = [] + state_list += mfa_totp.get_state(user) + return state_list + +def get_public_mfa_state(email, env): + '''return details about what MFA schemes are enabled for a user + ordered by the priority that the scheme will be tried, with index + zero being the first. No secrets are returned by this function - + only those details that are needed by the end user to identify a + particular MFA by label and the id of each so it may be disabled. + + ''' + mfa_state = get_mfa_state(email, env) + return [ + { "id": s["id"], "type": s["type"], "label": s["label"] } + for s in mfa_state + ] + +def get_hash_mfa_state(email, env): + '''return details about what MFA schemes are enabled for a user + ordered by the priority that the scheme will be tried, with index + zero being the first. This function may return secrets. It's + intended use is for the result to be included as part of the input + to a hashing function to generate a user api key (see + auth.py:create_user_key) + + ''' + mfa_state = get_mfa_state(email, env) + return [ + { "id": s["id"], "type": s["type"], "secret": s["secret"] } + for s in mfa_state + ] + +def enable_mfa(email, type, secret, token, label, env): + '''enable MFA using the scheme specified in `type`. users may have +multiple mfa schemes enabled of the same type. + + ''' + user = get_mfa_user(email, env) + if type == "totp": + mfa_totp.enable(user, secret, token, label, env) + else: + raise ValueError("Invalid MFA type.") + +def disable_mfa(email, mfa_id, env): + '''disable a specific MFA scheme. `mfa_id` identifies the specific + entry and is available in the `id` field of an item in the list + obtained from get_mfa_state() + + ''' + user = get_mfa_user(email, env) + if mfa_id is None: + # Disable all MFA for a user. + return mfa_totp.disable(user, None, env) + elif mfa_id.startswith("totp:"): + # Disable a particular MFA mode for a user. + return mfa_totp.disable(user, mfa_id, env) + else: + return False + +def validate_auth_mfa(email, request, env): + # Validates that a login request satisfies any MFA modes + # that have been enabled for the user's account. Returns + # a tuple (status, [hints]). status is True for a successful + # MFA login, False for a missing token. If status is False, + # hints is an array of codes that indicate what the user + # can try. Possible codes are: + # "missing-totp-token" + # "invalid-totp-token" + + mfa_state = get_mfa_state(email, env) + + # If no MFA modes are added, return True. + if len(mfa_state) == 0: + return (True, []) + + # Try the enabled MFA modes. + hints = set() + for mfa_mode in mfa_state: + if mfa_mode["type"] == "totp": + user = get_mfa_user(email, env) + result, hint = mfa_totp.validate_auth(user, mfa_mode, request, True, env) + if not result: + hints.add(hint) + else: + return (True, []) + + # On a failed login, indicate failure and any hints for what the user can do instead. + return (False, list(hints)) diff --git a/management/mfa_totp.py b/management/mfa_totp.py new file mode 100644 index 00000000..3af8f9a6 --- /dev/null +++ b/management/mfa_totp.py @@ -0,0 +1,178 @@ +# -*- indent-tabs-mode: t; tab-width: 4; python-indent-offset: 4; -*- +import base64 +import hmac +import pyotp +import qrcode +import io +import os +import time + +from mailconfig import open_database + +def id_from_index(user, index): + '''return a unique id for the user's totp entry. the index itself + should be avoided to ensure a change in the order does not cause + an unexpected change. + + ''' + return 'totp:' + user['totpMruTokenTime'][index] + +def index_from_id(user, id): + '''return the index of the corresponding id from the list of totp + entries for a user, or -1 if not found + + ''' + for index in range(0, len(user['totpSecret'])): + xid = id_from_index(user, index) + if xid == id: + return index + return -1 + +def time_ns(): + if "time_ns" in dir(time): + return time.time_ns() + else: + return int(time.time() * 1000000000) + +def get_state(user): + state_list = [] + + # totp + for idx in range(0, len(user['totpSecret'])): + state_list.append({ + 'id': id_from_index(user, idx), + 'type': 'totp', + 'secret': user['totpSecret'][idx], + 'mru_token': user['totpMruToken'][idx], + 'label': user['totpLabel'][idx] + }) + return state_list + +def enable(user, secret, token, label, env): + validate_secret(secret) + # Sanity check with the provide current token. + totp = pyotp.TOTP(secret) + if not totp.verify(token, valid_window=1): + raise ValueError("Invalid token.") + + mods = { + "totpSecret": user['totpSecret'].copy() + [secret], + "totpMruToken": user['totpMruToken'].copy() + [''], + "totpMruTokenTime": user['totpMruTokenTime'].copy() + [time_ns()], + "totpLabel": user['totpLabel'].copy() + [label or ''] + } + if 'totpUser' not in user['objectClass']: + mods['objectClass'] = user['objectClass'].copy() + ['totpUser'] + + conn = open_database(env) + conn.modify_record(user, mods) + +def set_mru_token(user, id, token, env): + # return quietly if the user is not configured for TOTP + if 'totpUser' not in user['objectClass']: return + + # ensure the id is valid + idx = index_from_id(user, id) + if idx<0: + raise ValueError('MFA/totp mru index is out of range') + + # store the token + mods = { + "totpMruToken": user['totpMruToken'].copy(), + "totpMruTokenTime": user['totpMruTokenTime'].copy() + } + mods['totpMruToken'][idx] = token + mods['totpMruTokenTime'][idx] = time_ns() + conn = open_database(env) + conn.modify_record(user, mods) + + +def disable(user, id, env): + # Disable a particular MFA mode for a user. + if id is None: + # Disable all totp + mods = { + "objectClass": user["objectClass"].copy(), + "totpMruToken": None, + "totpMruTokenTime": None, + "totpSecret": None, + "totpLabel": None + } + mods["objectClass"].remove("totpUser") + open_database(env).modify_record(user, mods) + return True + + else: + # Disable totp at the index specified + idx = index_from_id(user, id) + if idx<0 or idx>=len(user['totpSecret']): + return False + mods = { + "objectClass": user["objectClass"].copy(), + "totpMruToken": user["totpMruToken"].copy(), + "totpMruTokenTime": user["totpMruTokenTime"].copy(), + "totpSecret": user["totpSecret"].copy(), + "totpLabel": user["totpLabel"].copy() + } + mods["totpMruToken"].pop(idx) + mods["totpMruTokenTime"].pop(idx) + mods["totpSecret"].pop(idx) + mods["totpLabel"].pop(idx) + if len(mods["totpSecret"])==0: + mods['objectClass'].remove('totpUser') + open_database(env).modify_record(user, mods) + return True + + +def validate_secret(secret): + if type(secret) != str or secret.strip() == "": + raise ValueError("No secret provided.") + if len(secret) != 32: + raise ValueError("Secret should be a 32 characters base32 string") + +def provision(email, env): + # Make a new secret. + secret = base64.b32encode(os.urandom(20)).decode('utf-8') + validate_secret(secret) # sanity check + + # Make a URI that we encode within a QR code. + uri = pyotp.TOTP(secret).provisioning_uri( + name=email, + issuer_name=env["PRIMARY_HOSTNAME"] + " Mail-in-a-Box Control Panel" + ) + + # Generate a QR code as a base64-encode PNG image. + qr = qrcode.make(uri) + byte_arr = io.BytesIO() + qr.save(byte_arr, format='PNG') + png_b64 = base64.b64encode(byte_arr.getvalue()).decode('utf-8') + + return { + "type": "totp", + "secret": secret, + "qr_code_base64": png_b64 + } + + +def validate_auth(user, state, request, save_mru, env): + # Check that a token is present in the X-Auth-Token header. + # If not, give a hint that one can be supplied. + token = request.headers.get('x-auth-token') + if not token: + return (False, "missing-totp-token") + + # Check for a replay attack. + if hmac.compare_digest(token, state['mru_token'] or ""): + # If the token fails, skip this MFA mode. + return (False, "invalid-totp-token") + + # Check the token. + totp = pyotp.TOTP(state["secret"]) + if not totp.verify(token, valid_window=1): + return (False, "invalid-totp-token") + + # On success, record the token to prevent a replay attack. + if save_mru: + set_mru_token(user, state['id'], token, env) + + return (True, None) diff --git a/management/templates/index.html b/management/templates/index.html index 2c0d5a9a..12f6ad8e 100644 --- a/management/templates/index.html +++ b/management/templates/index.html @@ -97,11 +97,14 @@
  • Contacts/Calendar
  • @@ -131,6 +134,10 @@ {% include "custom-dns.html" %} +
    + {% include "mfa.html" %} +
    +
    {% include "login.html" %}
    @@ -292,7 +299,7 @@ function ajax_with_indicator(options) { } var api_credentials = ["", ""]; -function api(url, method, data, callback, callback_error) { +function api(url, method, data, callback, callback_error, headers) { // from http://www.webtoolkit.info/javascript-base64.html function base64encode(input) { _keyStr = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/="; @@ -330,7 +337,7 @@ function api(url, method, data, callback, callback_error) { method: method, cache: false, data: data, - + headers: headers, // the custom DNS api sends raw POST/PUT bodies --- prevent URL-encoding processData: typeof data != "string", mimeType: typeof data == "string" ? "text/plain; charset=ascii" : null, @@ -358,6 +365,16 @@ function api(url, method, data, callback, callback_error) { var current_panel = null; var switch_back_to_panel = null; + +function do_logout() { + api_credentials = ["", ""]; + if (typeof localStorage != 'undefined') + localStorage.removeItem("miab-cp-credentials"); + if (typeof sessionStorage != 'undefined') + sessionStorage.removeItem("miab-cp-credentials"); + show_panel('login'); +} + function show_panel(panelid) { if (panelid.getAttribute) // we might be passed an HTMLElement . diff --git a/management/templates/login.html b/management/templates/login.html index b6e74df6..4c432aae 100644 --- a/management/templates/login.html +++ b/management/templates/login.html @@ -1,4 +1,29 @@ -

    {{hostname}}

    + + +

    {{hostname}}

    {% if no_users_exist or no_admins_exist %}
    @@ -7,23 +32,23 @@

    There are no users on this system! To make an administrative user, log into this machine using SSH (like when you first set it up) and run:

    cd mailinabox
    -sudo tools/mail.py user add me@{{hostname}}
    -sudo tools/mail.py user make-admin me@{{hostname}}
    +sudo management/cli.py user add me@{{hostname}} +sudo management/cli.py user make-admin me@{{hostname}} {% else %}

    There are no administrative users on this system! To make an administrative user, log into this machine using SSH (like when you first set it up) and run:

    cd mailinabox
    -sudo tools/mail.py user make-admin me@{{hostname}}
    +sudo management/cli.py user make-admin me@{{hostname}} {% endif %}
    {% endif %} -

    Log in here for your Mail-in-a-Box control panel.

    +

    Log in here for your Mail-in-a-Box control panel.

    -
    -
    +