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/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..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 b7bf2a66..ffc6d5d5 100755 --- a/management/daemon.py +++ b/management/daemon.py @@ -1,14 +1,15 @@ 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 mfa import get_public_mfa_state, provision_totp, validate_totp_secret, enable_mfa, disable_mfa env = utils.load_environment() @@ -35,23 +36,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 +92,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 +128,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 = { @@ -334,7 +348,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 = [ @@ -383,6 +397,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 b061ea7d..47faad5f 100755 --- a/management/mailconfig.py +++ b/management/mailconfig.py @@ -608,7 +608,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/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.

    -
    -
    +