From 545e7a52e465f5abc5ecabdc2d446d719febe7e6 Mon Sep 17 00:00:00 2001 From: Joshua Tauberer Date: Thu, 29 Oct 2020 15:41:34 -0400 Subject: [PATCH] Add MFA list/disable to the management CLI so admins can restore access if MFA device is lost --- api/mailinabox.yml | 10 ++++----- management/cli.py | 22 +++++++++++++++---- management/daemon.py | 40 +++++++++++++++++++++++++++-------- management/mfa.py | 1 + management/templates/mfa.html | 2 +- tools/mail.py | 3 +++ 6 files changed, 59 insertions(+), 19 deletions(-) create mode 100755 tools/mail.py diff --git a/api/mailinabox.yml b/api/mailinabox.yml index 15a048f9..a9a2c124 100644 --- a/api/mailinabox.yml +++ b/api/mailinabox.yml @@ -1666,16 +1666,16 @@ paths: schema: type: string /mfa/status: - get: + post: tags: - MFA - summary: Retrieve MFA status + 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 GET "https://{host}/admin/mfa/status" \ + curl -X POST "https://{host}/admin/mfa/status" \ -u ":" responses: 200: @@ -1733,8 +1733,8 @@ paths: post: tags: - MFA - summary: Disable multi-factor authentication - description: Disables multi-factor authentication for the currently logged-in admin user. Either disables all multi-factor authentication methods or the method corresponding to the optional property `mfa_id` + 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 diff --git a/management/cli.py b/management/cli.py index f264fa70..1b91b003 100755 --- a/management/cli.py +++ b/management/cli.py @@ -6,7 +6,7 @@ # 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 +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.) @@ -60,14 +60,16 @@ def setup_key_auth(mgmt_uri): if len(sys.argv) < 2: print("""Usage: - {cli} user (lists users) + {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} alias (lists aliases) + {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 @@ -121,6 +123,18 @@ elif sys.argv[1] == "user" and sys.argv[2] == "admins": 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")) diff --git a/management/daemon.py b/management/daemon.py index 04b109f7..ffc6d5d5 100755 --- a/management/daemon.py +++ b/management/daemon.py @@ -5,7 +5,7 @@ from functools import wraps from flask import Flask, request, render_template, abort, Response, send_from_directory, make_response -import auth, utils, mfa +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 @@ -399,15 +399,27 @@ def ssl_provision_certs(): # multi-factor auth -@app.route('/mfa/status', methods=['GET']) +@app.route('/mfa/status', methods=['POST']) @authorized_personnel_only def mfa_get_status(): - return json_response({ - "enabled_mfa": get_public_mfa_state(request.user_email, env), - "new_mfa": { - "totp": provision_totp(request.user_email, env) + # 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 @@ -427,8 +439,18 @@ def totp_post_enable(): @app.route('/mfa/disable', methods=['POST']) @authorized_personnel_only def totp_post_disable(): - disable_mfa(request.user_email, request.form.get('mfa-id'), env) - return "OK" + # 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/mfa.py b/management/mfa.py index 6af2288e..32eb5183 100644 --- a/management/mfa.py +++ b/management/mfa.py @@ -63,6 +63,7 @@ def disable_mfa(email, mfa_id, env): # 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() == "": diff --git a/management/templates/mfa.html b/management/templates/mfa.html index 8e2737c1..f45b263f 100644 --- a/management/templates/mfa.html +++ b/management/templates/mfa.html @@ -186,7 +186,7 @@ and ensure every administrator account for this control panel does the same.