From 601c23d91bed5f47222a26d96c61571f17efe10b Mon Sep 17 00:00:00 2001
From: Joshua Tauberer <jt@occams.info>
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..2c27b0b9 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 "<email>:<password>"
       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 subimtted. 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.</st
 
         api(
             '/mfa/status',
-            'GET',
+            'POST',
             {},
             function(res) {
                 el.wrapper.classList.add('loaded');
diff --git a/tools/mail.py b/tools/mail.py
new file mode 100755
index 00000000..f7d5b410
--- /dev/null
+++ b/tools/mail.py
@@ -0,0 +1,3 @@
+#!/bin/bash
+# This script has moved.
+management/cli.py "$@"