diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c10d78e..bd1745a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,23 @@ CHANGELOG ========= +v0.51 (November 14, 2020) +------------------------- + +Software updates: + +* Upgraded Nextcloud from 17.0.6 to 20.0.1 (with Contacts from 3.3.0 to 3.4.1 and Calendar from 2.0.3 to 2.1.2) +* Upgraded Roundcube to version 1.4.9. + +Mail: + +* The MTA-STA max_age value was increased to the normal one week. + +Control Panel: + +* Two-factor authentication can now be enabled for logins to the control panel. However, keep in mind that many online services (including domain name registrars, cloud server providers, and TLS certificate providers) may allow an attacker to take over your account or issue a fraudulent TLS certificate with only access to your email address, and this new two-factor authentication does not protect access to your inbox. It therefore remains very important that user accounts with administrative email addresses have strong passwords. +* TLS certificate expiry dates are now shown in ISO8601 format for clarity. + v0.50 (September 25, 2020) -------------------------- diff --git a/README.md b/README.md index 28f3d2f8..0d417d14 100644 --- a/README.md +++ b/README.md @@ -249,7 +249,7 @@ by him: $ curl -s https://keybase.io/joshdata/key.asc | gpg --import gpg: key C10BDD81: public key "Joshua Tauberer " imported - $ git verify-tag v0.50 + $ git verify-tag v0.51 gpg: Signature made ..... using RSA key ID C10BDD81 gpg: Good signature from "Joshua Tauberer " gpg: WARNING: This key is not certified with a trusted signature! @@ -262,7 +262,7 @@ and on his [personal homepage](https://razor.occams.info/). (Of course, if this Checkout the tag corresponding to the most recent release: - $ git checkout v0.50 + $ git checkout v0.51 Begin the installation. 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/mta-sts.txt b/conf/mta-sts.txt index 376102bc..26acc015 100644 --- a/conf/mta-sts.txt +++ b/conf/mta-sts.txt @@ -1,4 +1,4 @@ version: STSv1 mode: MODE mx: PRIMARY_HOSTNAME -max_age: 86400 \ No newline at end of file +max_age: 604800 diff --git a/conf/nginx-primaryonly.conf b/conf/nginx-primaryonly.conf index 288fce40..31bf0095 100644 --- a/conf/nginx-primaryonly.conf +++ b/conf/nginx-primaryonly.conf @@ -22,20 +22,20 @@ rewrite ^(/cloud/oc[sm]-provider)/$ $1/index.php redirect; location /cloud/ { alias /usr/local/lib/owncloud/; - location ~ ^/cloud/(build|tests|config|lib|3rdparty|templates|data|README)/ { - deny all; - } - location ~ ^/cloud/(?:\.|autotest|occ|issue|indie|db_|console) { - deny all; - } + location ~ ^/cloud/(build|tests|config|lib|3rdparty|templates|data|README)/ { + deny all; + } + location ~ ^/cloud/(?:\.|autotest|occ|issue|indie|db_|console) { + deny all; + } # Enable paths for service and cloud federation discovery # Resolves warning in Nextcloud Settings panel - location ~ ^/cloud/(oc[sm]-provider)?/([^/]+\.php)$ { - index index.php; - include fastcgi_params; - fastcgi_param SCRIPT_FILENAME /usr/local/lib/owncloud/$1/$2; - fastcgi_pass php-fpm; - } + location ~ ^/cloud/(oc[sm]-provider)?/([^/]+\.php)$ { + index index.php; + include fastcgi_params; + fastcgi_param SCRIPT_FILENAME /usr/local/lib/owncloud/$1/$2; + fastcgi_pass php-fpm; + } } location ~ ^(/cloud)((?:/ocs)?/[^/]+\.php)(/.*)?$ { # note: ~ has precendence over a regular location block diff --git a/management/auth.py b/management/auth.py index 55f59664..fd143c76 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 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 == "": @@ -112,6 +115,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. @@ -122,16 +131,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" " + 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/cli.py b/management/cli.py new file mode 100755 index 00000000..9d4e89f2 --- /dev/null +++ b/management/cli.py @@ -0,0 +1,170 @@ +#!/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} system default-quota [new default] (set default quota for system) + {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 quota user@domain [new-quota] + {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='') + if user['quota'] == '0': + print(" unlimited", end='') + else: + print(" " + user['quota'], 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 sys.argv[2] == "quota" and len(sys.argv) == 4: + # Set a user's quota + print(mgmt("/mail/users/quota?text=1&email=%s" % sys.argv[3])) + +elif sys.argv[1] == "user" and sys.argv[2] == "quota" and len(sys.argv) == 5: + # Set a user's quota + users = mgmt("/mail/users/quota", { "email": sys.argv[3], "quota": sys.argv[4] }) + +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] })) + +elif sys.argv[1] == "system" and sys.argv[2] == "default-quota" and len(sys.argv) == 3: + print(mgmt("/system/default-quota?text=1")) + +elif sys.argv[1] == "system" and sys.argv[2] == "default-quota" and len(sys.argv) == 4: + print(mgmt("/system/default-quota", { "default_quota": sys.argv[3]})) + +else: + print("Invalid command-line arguments.") + sys.exit(1) + diff --git a/management/daemon.py b/management/daemon.py index 00f756e9..a1c07af7 100755 --- a/management/daemon.py +++ b/management/daemon.py @@ -1,15 +1,17 @@ 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, 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 mailconfig import get_mail_quota, set_mail_quota, get_default_quota, validate_quota +from mfa import get_public_mfa_state, provision_totp, validate_totp_secret, enable_mfa, disable_mfa + env = utils.load_environment() auth_service = auth.KeyAuthService() @@ -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 = { @@ -357,7 +372,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 = [ @@ -406,6 +421,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": provision_totp(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: + validate_totp_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 43c4777e..aa992f33 100755 --- a/management/mailconfig.py +++ b/management/mailconfig.py @@ -720,7 +720,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..32eb5183 --- /dev/null +++ b/management/mfa.py @@ -0,0 +1,141 @@ +import base64 +import hmac +import io +import os +import pyotp +import qrcode + +from mailconfig import open_database + +def get_user_id(email, c): + c.execute('SELECT id FROM users WHERE email=?', (email,)) + r = c.fetchone() + if not r: raise ValueError("User does not exist.") + return r[0] + +def get_mfa_state(email, env): + c = open_database(env) + c.execute('SELECT id, type, secret, mru_token, label FROM mfa WHERE user_id=?', (get_user_id(email, c),)) + return [ + { "id": r[0], "type": r[1], "secret": r[2], "mru_token": r[3], "label": r[4] } + for r in c.fetchall() + ] + +def get_public_mfa_state(email, env): + 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): + 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): + if type == "totp": + validate_totp_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.") + else: + raise ValueError("Invalid MFA type.") + + conn, c = open_database(env, with_connection=True) + c.execute('INSERT INTO mfa (user_id, type, secret, label) VALUES (?, ?, ?, ?)', (get_user_id(email, c), type, secret, label)) + conn.commit() + +def set_mru_token(email, mfa_id, token, env): + conn, c = open_database(env, with_connection=True) + c.execute('UPDATE mfa SET mru_token=? WHERE user_id=? AND id=?', (token, get_user_id(email, c), mfa_id)) + conn.commit() + +def disable_mfa(email, mfa_id, env): + conn, c = open_database(env, with_connection=True) + if mfa_id is None: + # Disable all MFA for a user. + c.execute('DELETE FROM mfa WHERE user_id=?', (get_user_id(email, c),)) + else: + # Disable a particular MFA mode for a user. + c.execute('DELETE FROM mfa WHERE user_id=? AND id=?', (get_user_id(email, c), mfa_id)) + conn.commit() + return c.rowcount > 0 + +def validate_totp_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_totp(email, env): + # Make a new secret. + secret = base64.b32encode(os.urandom(20)).decode('utf-8') + validate_totp_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_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": + # 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: + hints.add("missing-totp-token") + continue + + # Check for a replay attack. + if hmac.compare_digest(token, mfa_mode['mru_token'] or ""): + # If the token fails, skip this MFA mode. + hints.add("invalid-totp-token") + continue + + # Check the token. + totp = pyotp.TOTP(mfa_mode["secret"]) + if not totp.verify(token, valid_window=1): + hints.add("invalid-totp-token") + continue + + # On success, record the token to prevent a replay attack. + set_mru_token(email, mfa_mode['id'], token, env) + 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/ssl_certificates.py b/management/ssl_certificates.py index 1b1e9f83..3e1b5856 100755 --- a/management/ssl_certificates.py +++ b/management/ssl_certificates.py @@ -216,12 +216,12 @@ def get_certificates_to_provision(env, limit_domains=None, show_valid_certs=True response = query_dns(domain, rtype) if response != normalize_ip(value): bad_dns.append("%s (%s)" % (response, rtype)) - + if bad_dns: domains_cant_provision[domain] = "The domain name does not resolve to this machine: " \ + (", ".join(bad_dns)) \ + "." - + else: # DNS is all good. @@ -606,10 +606,10 @@ def check_certificate(domain, ssl_certificate, ssl_private_key, warn_if_expiring ndays = (cert_expiration_date-now).days if not rounded_time or ndays <= 10: # Yikes better renew soon! - expiry_info = "The certificate expires in %d days on %s." % (ndays, cert_expiration_date.strftime("%x")) + expiry_info = "The certificate expires in %d days on %s." % (ndays, cert_expiration_date.date().isoformat()) else: # We'll renew it with Lets Encrypt. - expiry_info = "The certificate expires on %s." % cert_expiration_date.strftime("%x") + expiry_info = "The certificate expires on %s." % cert_expiration_date.date().isoformat() if warn_if_expiring_soon and ndays <= warn_if_expiring_soon: # Warn on day 10 to give 4 days for us to automatically renew the 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.

    -
    -
    +