From 700188c44392aaa3a1e5cd5feaa59767db38cb53 Mon Sep 17 00:00:00 2001
From: Joshua Tauberer <jt@occams.info>
Date: Sun, 22 Aug 2021 14:29:33 -0400
Subject: [PATCH 1/6] Roundcube 1.5 RC

---
 setup/webmail.sh | 5 +++--
 1 file changed, 3 insertions(+), 2 deletions(-)

diff --git a/setup/webmail.sh b/setup/webmail.sh
index 55fea631..d3652975 100755
--- a/setup/webmail.sh
+++ b/setup/webmail.sh
@@ -29,8 +29,8 @@ apt_install \
 # Combine the Roundcube version number with the commit hash of plugins to track
 # whether we have the latest version of everything.
 
-VERSION=1.4.11
-HASH=3877f0e70f29e7d0612155632e48c3db1e626be3
+VERSION=1.5-rc
+HASH=a7cb2a39702536d769c7ff93f716e27f0b93f9d9
 PERSISTENT_LOGIN_VERSION=6b3fc450cae23ccb2f393d0ef67aa319e877e435 # version 5.2.0
 HTML5_NOTIFIER_VERSION=68d9ca194212e15b3c7225eb6085dbcf02fd13d7 # version 0.6.4+
 CARDDAV_VERSION=3.0.3
@@ -132,6 +132,7 @@ cat > $RCM_CONFIG <<EOF;
 \$config['plugins'] = array('html5_notifier', 'archive', 'zipdownload', 'password', 'managesieve', 'jqueryui', 'persistent_login', 'carddav');
 \$config['skin'] = 'elastic';
 \$config['login_autocomplete'] = 2;
+\$config['login_username_filter'] = 'email';
 \$config['password_charset'] = 'UTF-8';
 \$config['junk_mbox'] = 'Spam';
 ?>

From 53ec0f39cb074dc43a2f8b245aa8d4d12c74914e Mon Sep 17 00:00:00 2001
From: Joshua Tauberer <jt@occams.info>
Date: Sun, 22 Aug 2021 15:02:38 -0400
Subject: [PATCH 2/6] Use 'secrets' to generate the system API key and remove
 some debugging-related code

* Rename the 'master' API key to be called the 'system' API key
* Generate the key using the Python secrets module which is meant for this
* Remove some debugging helper code which will be obsoleted by the upcoming changes for session keys
---
 management/auth.py   | 29 ++++++++++++-----------------
 management/daemon.py | 27 +++++----------------------
 2 files changed, 17 insertions(+), 39 deletions(-)

diff --git a/management/auth.py b/management/auth.py
index fd143c76..de2b61b5 100644
--- a/management/auth.py
+++ b/management/auth.py
@@ -1,6 +1,5 @@
-import base64, os, os.path, hmac, json
+import base64, os, os.path, hmac, json, secrets
 
-from flask import make_response
 
 import utils
 from mailconfig import get_mail_password, get_mail_user_privileges
@@ -9,7 +8,7 @@ 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'
 
-class KeyAuthService:
+class AuthService:
 	"""Generate an API key for authenticating clients
 
 	Clients must read the key from the key file and send the key with all HTTP
@@ -18,16 +17,12 @@ class KeyAuthService:
 	"""
 	def __init__(self):
 		self.auth_realm = DEFAULT_AUTH_REALM
-		self.key = self._generate_key()
 		self.key_path = DEFAULT_KEY_PATH
+		self.init_system_api_key()
 
-	def write_key(self):
-		"""Write key to file so authorized clients can get the key
+	def init_system_api_key(self):
+		"""Write an API key to a local file so local processes can use the API"""
 
-		The key file is created with mode 0640 so that additional users can be
-		authorized to access the API by granting group/ACL read permissions on
-		the key file.
-		"""
 		def create_file_with_mode(path, mode):
 			# Based on answer by A-B-B: http://stackoverflow.com/a/15015748
 			old_umask = os.umask(0)
@@ -36,6 +31,8 @@ class KeyAuthService:
 			finally:
 				os.umask(old_umask)
 
+		self.key = secrets.token_hex(24)
+
 		os.makedirs(os.path.dirname(self.key_path), exist_ok=True)
 
 		with create_file_with_mode(self.key_path, 0o640) as key_file:
@@ -72,8 +69,9 @@ class KeyAuthService:
 
 		if username in (None, ""):
 			raise ValueError("Authorization header invalid.")
-		elif username == self.key:
-			# The user passed the master API key which grants administrative privs.
+
+		if username == self.key:
+			# The user passed the system API key which grants administrative privs.
 			return (None, ["admin"])
 		else:
 			# The user is trying to log in with a username and either a password
@@ -136,8 +134,8 @@ class KeyAuthService:
 		# 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
+		# Use an HMAC to generate the API key using our system API key as a key,
+		# which also means that the API key becomes invalid when our system API key
 		# changes --- i.e. when this process is restarted.
 		#
 		# Raises ValueError via get_mail_password if the user doesn't exist.
@@ -153,6 +151,3 @@ class KeyAuthService:
 		hash_key = self.key.encode('ascii')
 		return hmac.new(hash_key, msg, digestmod="sha256").hexdigest()
 
-	def _generate_key(self):
-		raw_key = os.urandom(32)
-		return base64.b64encode(raw_key).decode('ascii')
diff --git a/management/daemon.py b/management/daemon.py
index 8490ee44..bb723ea6 100755
--- a/management/daemon.py
+++ b/management/daemon.py
@@ -1,5 +1,8 @@
 #!/usr/local/lib/mailinabox/env/bin/python3
 #
+# The API can be accessed on the command line, e.g. use `curl` like so:
+#    curl --user $(</var/lib/mailinabox/api.key): http://localhost:10222/mail/users
+#
 # During development, you can start the Mail-in-a-Box control panel
 # by running this script, e.g.:
 #
@@ -22,7 +25,7 @@ from mfa import get_public_mfa_state, provision_totp, validate_totp_secret, enab
 
 env = utils.load_environment()
 
-auth_service = auth.KeyAuthService()
+auth_service = auth.AuthService()
 
 # We may deploy via a symbolic link, which confuses flask's template finding.
 me = __file__
@@ -724,30 +727,10 @@ if __name__ == '__main__':
 		# Turn on Flask debugging.
 		app.debug = True
 
-		# Use a stable-ish master API key so that login sessions don't restart on each run.
-		# Use /etc/machine-id to seed the key with a stable secret, but add something
-		# and hash it to prevent possibly exposing the machine id, using the time so that
-		# the key is not valid indefinitely.
-		import hashlib
-		with open("/etc/machine-id") as f:
-			api_key = f.read()
-		api_key += "|" + str(int(time.time() / (60*60*2)))
-		hasher = hashlib.sha1()
-		hasher.update(api_key.encode("ascii"))
-		auth_service.key = hasher.hexdigest()
-
-	if "APIKEY" in os.environ: auth_service.key = os.environ["APIKEY"]
-
 	if not app.debug:
 		app.logger.addHandler(utils.create_syslog_handler())
 
-	# For testing on the command line, you can use `curl` like so:
-	#    curl --user $(</var/lib/mailinabox/api.key): http://localhost:10222/mail/users
-	auth_service.write_key()
-
-	# For testing in the browser, you can copy the API key that's output to the
-	# debug console and enter that as the username
-	app.logger.info('API key: ' + auth_service.key)
+	#app.logger.info('API key: ' + auth_service.key)
 
 	# Start the application server. Listens on 127.0.0.1 (IPv4 only).
 	app.run(port=10222)

From e884c4774f3ef4fc01e43595e46948456a76ddbf Mon Sep 17 00:00:00 2001
From: Joshua Tauberer <jt@occams.info>
Date: Sun, 22 Aug 2021 16:07:16 -0400
Subject: [PATCH 3/6] Replace HMAC-based session API keys with tokens stored in
 memory in the daemon process

Since the session cache clears keys after a period of time, this fixes #1821.

Based on https://github.com/mail-in-a-box/mailinabox/pull/2012, and so:

Co-Authored-By: NewbieOrange <NewbieOrange@users.noreply.github.com>

Also fixes #2029 by not revealing through the login failure error message whether a user exists or not.
---
 api/mailinabox.yml              |  34 +++++--
 management/auth.py              | 173 +++++++++++++++++---------------
 management/daemon.py            |  29 ++++--
 management/templates/index.html |   6 ++
 management/templates/login.html |   4 +-
 setup/management.sh             |   4 +-
 tests/fail2ban.py               |   2 +-
 7 files changed, 149 insertions(+), 103 deletions(-)

diff --git a/api/mailinabox.yml b/api/mailinabox.yml
index 14cf54de..bd4b203b 100644
--- a/api/mailinabox.yml
+++ b/api/mailinabox.yml
@@ -54,24 +54,24 @@ tags:
       System operations, which include system status checks, new version checks
       and reboot status.
 paths:
-  /me:
-    get:
+  /login:
+    post:
       tags:
         - User
-      summary: Get user information
+      summary: Exchange a username and password for a session API key.
       description: |
-        Returns user information. Used for user authentication.
+        Returns user information and a session API key.
 
         Authenticate a user by supplying the auth token as a base64 encoded string in
         format `email:password` using basic authentication headers.
 
         If successful, a long-lived `api_key` is returned which can be used for subsequent
-        requests to the API.
-      operationId: getMe
+        requests to the API in place of the password.
+      operationId: login
       x-codeSamples:
         - lang: curl
           source: |
-            curl -X GET "https://{host}/admin/me" \
+            curl -X GET "https://{host}/admin/login" \
               -u "<email>:<password>"
       responses:
         200:
@@ -92,6 +92,24 @@ paths:
                     privileges:
                       - admin
                     status: ok
+  /logout:
+    post:
+      tags:
+        - User
+      summary: Invalidates a session API key.
+      description: |
+        Invalidates a session API key so that it cannot be used after this API call.
+      operationId: logout
+      x-codeSamples:
+        - lang: curl
+          source: |
+            curl -X GET "https://{host}/admin/logout" \
+              -u "<email>:<session_key>"
+      responses:
+        200:
+          description: Successful operation
+          content:
+            application/json:
   /system/status:
     post:
       tags:
@@ -1803,7 +1821,7 @@ components:
 
         The `access-token` is comprised of the Base64 encoding of `username:password`.
         The `username` is the mail user's email address, and `password` can either be the mail user's
-        password, or the `api_key` returned from the `getMe` operation.
+        password, or the `api_key` returned from the `login` operation.
 
         When using `curl`, you can supply user credentials using the `-u` or `--user` parameter.
   requestBodies:
diff --git a/management/auth.py b/management/auth.py
index de2b61b5..38c15e91 100644
--- a/management/auth.py
+++ b/management/auth.py
@@ -1,5 +1,7 @@
 import base64, os, os.path, hmac, json, secrets
+from datetime import timedelta
 
+from expiringdict import ExpiringDict
 
 import utils
 from mailconfig import get_mail_password, get_mail_user_privileges
@@ -9,16 +11,13 @@ DEFAULT_KEY_PATH   = '/var/lib/mailinabox/api.key'
 DEFAULT_AUTH_REALM = 'Mail-in-a-Box Management Server'
 
 class AuthService:
-	"""Generate an API key for authenticating clients
-
-	Clients must read the key from the key file and send the key with all HTTP
-	requests. The key is passed as the username field in the standard HTTP
-	Basic Auth header.
-	"""
 	def __init__(self):
 		self.auth_realm = DEFAULT_AUTH_REALM
 		self.key_path = DEFAULT_KEY_PATH
+		self.max_session_duration = timedelta(days=2)
+
 		self.init_system_api_key()
+		self.sessions = ExpiringDict(max_len=64, max_age_seconds=self.max_session_duration.total_seconds())
 
 	def init_system_api_key(self):
 		"""Write an API key to a local file so local processes can use the API"""
@@ -31,123 +30,133 @@ class AuthService:
 			finally:
 				os.umask(old_umask)
 
-		self.key = secrets.token_hex(24)
+		self.key = secrets.token_hex(32)
 
 		os.makedirs(os.path.dirname(self.key_path), exist_ok=True)
 
 		with create_file_with_mode(self.key_path, 0o640) as key_file:
 			key_file.write(self.key + '\n')
 
-	def authenticate(self, request, env):
-		"""Test if the client key passed in HTTP Authorization header matches the service key
-		or if the or username/password passed in the header matches an administrator user.
+	def authenticate(self, request, env, login_only=False, logout=False):
+		"""Test if the HTTP Authorization header's username matches the system key, a session key,
+		or if the username/password passed in the header matches a local user.
 		Returns a tuple of the user's email address and list of user privileges (e.g.
 		('my@email', []) or ('my@email', ['admin']); raises a ValueError on login failure.
-		If the user used an API key, the user's email is returned as None."""
+		If the user used the system API key, the user's email is returned as None since
+		this key is not associated with a user."""
 
-		def decode(s):
-			return base64.b64decode(s.encode('ascii')).decode('ascii')
-
-		def parse_basic_auth(header):
+		def parse_http_authorization_basic(header):
+			def decode(s):
+				return base64.b64decode(s.encode('ascii')).decode('ascii')
 			if " " not in header:
 				return None, None
 			scheme, credentials = header.split(maxsplit=1)
 			if scheme != 'Basic':
 				return None, None
-
 			credentials = decode(credentials)
 			if ":" not in credentials:
 				return None, None
 			username, password = credentials.split(':', maxsplit=1)
 			return username, password
 
-		header = request.headers.get('Authorization')
-		if not header:
-			raise ValueError("No authorization header provided.")
-
-		username, password = parse_basic_auth(header)
-
+		username, password = parse_http_authorization_basic(request.headers.get('Authorization', ''))
 		if username in (None, ""):
 			raise ValueError("Authorization header invalid.")
 
-		if username == self.key:
-			# The user passed the system API key which grants administrative privs.
+		if username.strip() == "" and password.strip() == "":
+			raise ValueError("No email address, password, session key, or API key provided.")
+
+		# If user passed the system API key, grant administrative privs. This key
+		# is not associated with a user.
+		if username == self.key and not login_only:
 			return (None, ["admin"])
+
+		# If the password corresponds with a session token for the user, grant access for that user.
+		if password in self.sessions and self.sessions[password]["email"] == username and not login_only:
+			sessionid = password
+			session = self.sessions[sessionid]
+			if session["password_token"] != self.create_user_password_state_token(username, env):
+				# This session is invalid because the user's password/MFA state changed
+				# after the session was created.
+				del self.sessions[sessionid]
+				raise ValueError("Session expired.")
+			if logout:
+				# Clear the session.
+				del self.sessions[sessionid]
+			else:
+				# Re-up the session so that it does not expire.
+				self.sessions[sessionid] = session
+
+		# If no password was given, but a username was given, we're missing some information.
+		elif password.strip() == "":
+			raise ValueError("Enter a password.")
+
 		else:
-			# 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))
+			# The user is trying to log in with a username and a password
+			# (and possibly a MFA token). On failure, an exception is raised.
+			self.check_user_auth(username, password, request, env)
+
+		# Get privileges for authorization. This call should never fail because by this
+		# point we know the email address is a valid user --- unless the user has been
+		# deleted after the session was granted. On error the call will return a tuple
+		# of an error message and an HTTP status code.
+		privs = get_mail_user_privileges(username, env)
+		if isinstance(privs, tuple): raise ValueError(privs[0])
+
+		# Return the authorization information.
+		return (username, privs)
 
 	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.
+		# On login failure, raises a ValueError with a login error message. On
+		# success, nothing is returned.
 
-		# Sanity check.
-		if email == "" or pw == "":
-			raise ValueError("Enter an email address and password.")
-
-		# The password might be a user-specific API key. create_user_key raises
-		# a ValueError if the user does not exist.
-		if hmac.compare_digest(self.create_user_key(email, env), pw):
-			# OK.
-			pass
-		else:
+		# Authenticate.
+		try:
 			# Get the hashed password of the user. Raise a ValueError if the
-			# email address does not correspond to a user.
+			# email address does not correspond to a user. But wrap it in the
+			# same exception as if a password fails so we don't easily reveal
+			# if an email address is valid.
 			pw_hash = get_mail_password(email, env)
 
-			# Authenticate.
-			try:
-				# Use 'doveadm pw' to check credentials. doveadm will return
-				# a non-zero exit status if the credentials are no good,
-				# and check_call will raise an exception in that case.
-				utils.shell('check_call', [
-					"/usr/bin/doveadm", "pw",
-					"-p", pw,
-					"-t", pw_hash,
-					])
-			except:
-				# Login failed.
-				raise ValueError("Invalid password.")
+			# Use 'doveadm pw' to check credentials. doveadm will return
+			# a non-zero exit status if the credentials are no good,
+			# and check_call will raise an exception in that case.
+			utils.shell('check_call', [
+				"/usr/bin/doveadm", "pw",
+				"-p", pw,
+				"-t", pw_hash,
+				])
+		except:
+			# Login failed.
+			raise ValueError("Incorrect email address or 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))
+		# 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.
-		privs = get_mail_user_privileges(email, env)
-		if isinstance(privs, tuple): raise ValueError(privs[0])
-
-		# Return a list of privileges.
-		return privs
-
-	def create_user_key(self, email, env):
-		# 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 system API key as a key,
-		# which also means that the API key becomes invalid when our system 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")
+	def create_user_password_state_token(self, email, env):
+		# Create a token that changes if the user's password or MFA options change
+		# so that sessions become invalid if any of that information changes.
+		msg = get_mail_password(email, env).encode("utf8")
 
 		# 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.
+		# Make a HMAC using the system API key as a hash key.
 		hash_key = self.key.encode('ascii')
 		return hmac.new(hash_key, msg, digestmod="sha256").hexdigest()
 
+	def create_session_key(self, username, env, type=None):
+		# Create a new session.
+		token = secrets.token_hex(32)
+		self.sessions[token] = {
+			"email": username,
+			"password_token": self.create_user_password_state_token(username, env),
+		}
+		return token
diff --git a/management/daemon.py b/management/daemon.py
index bb723ea6..ca891772 100755
--- a/management/daemon.py
+++ b/management/daemon.py
@@ -56,8 +56,10 @@ def authorized_personnel_only(viewfunc):
 		try:
 			email, privs = auth_service.authenticate(request, env)
 		except ValueError as e:
-			# Write a line in the log recording the failed login
-			log_failed_login(request)
+			# Write a line in the log recording the failed login, unless no authorization header
+			# was given which can happen on an initial request before a 403 response.
+			if "Authorization" in request.headers:
+				log_failed_login(request)
 
 			# Authentication failed.
 			error = str(e)
@@ -134,11 +136,12 @@ def index():
 		csr_country_codes=csr_country_codes,
 	)
 
-@app.route('/me')
-def me():
+# Create a session key by checking the username/password in the Authorization header.
+@app.route('/login', methods=["POST"])
+def login():
 	# Is the caller authorized?
 	try:
-		email, privs = auth_service.authenticate(request, env)
+		email, privs = auth_service.authenticate(request, env, login_only=True)
 	except ValueError as e:
 		if "missing-totp-token" in str(e):
 			return json_response({
@@ -153,19 +156,29 @@ def me():
 				"reason": str(e),
 			})
 
+	# Return a new session for the user.
 	resp = {
 		"status": "ok",
 		"email": email,
 		"privileges": privs,
+		"api_key": auth_service.create_session_key(email, env, type='login'),
 	}
 
-	# Is authorized as admin? Return an API key for future use.
-	if "admin" in privs:
-		resp["api_key"] = auth_service.create_user_key(email, env)
+	app.logger.info("New login session created for {}".format(email))
 
 	# Return.
 	return json_response(resp)
 
+@app.route('/logout', methods=["POST"])
+def logout():
+	try:
+		email, _ = auth_service.authenticate(request, env, logout=True)
+		app.logger.info("{} logged out".format(email))
+	except ValueError as e:
+		pass
+	finally:
+		return json_response({ "status": "ok" })
+
 # MAIL
 
 @app.route('/mail/users')
diff --git a/management/templates/index.html b/management/templates/index.html
index 12f6ad8e..267f5dd6 100644
--- a/management/templates/index.html
+++ b/management/templates/index.html
@@ -367,11 +367,17 @@ var current_panel = null;
 var switch_back_to_panel = null;
 
 function do_logout() {
+  // Clear the session from the backend.
+  api("/logout", "POST");
+
+  // Forget the token.
   api_credentials = ["", ""];
   if (typeof localStorage != 'undefined')
     localStorage.removeItem("miab-cp-credentials");
   if (typeof sessionStorage != 'undefined')
     sessionStorage.removeItem("miab-cp-credentials");
+
+  // Return to the start.
   show_panel('login');
 }
 
diff --git a/management/templates/login.html b/management/templates/login.html
index 19b23d3a..3447d794 100644
--- a/management/templates/login.html
+++ b/management/templates/login.html
@@ -105,8 +105,8 @@ function do_login() {
   api_credentials = [$('#loginEmail').val(), $('#loginPassword').val()]
 
   api(
-  "/me",
-  "GET",
+  "/login",
+  "POST",
   {},
   function(response) {
     // This API call always succeeds. It returns a JSON object indicating
diff --git a/setup/management.sh b/setup/management.sh
index 1c57bb2e..7e31fe00 100755
--- a/setup/management.sh
+++ b/setup/management.sh
@@ -49,8 +49,8 @@ hide_output $venv/bin/pip install --upgrade pip
 # NOTE: email_validator is repeated in setup/questions.sh, so please keep the versions synced.
 hide_output $venv/bin/pip install --upgrade \
 	rtyaml "email_validator>=1.0.0" "exclusiveprocess" \
-	flask dnspython python-dateutil \
-  qrcode[pil] pyotp \
+	flask dnspython python-dateutil expiringdict \
+	qrcode[pil] pyotp \
 	"idna>=2.0.0" "cryptography==2.2.2" boto psutil postfix-mta-sts-resolver b2sdk
 
 # CONFIGURATION
diff --git a/tests/fail2ban.py b/tests/fail2ban.py
index 1cb55eba..cb55c51f 100644
--- a/tests/fail2ban.py
+++ b/tests/fail2ban.py
@@ -232,7 +232,7 @@ if __name__ == "__main__":
 	run_test(managesieve_test, [], 20, 30, 4)
 
 	# Mail-in-a-Box control panel
-	run_test(http_test, ["/admin/me", 200], 20, 30, 1)
+	run_test(http_test, ["/admin/login", 200], 20, 30, 1)
 
 	# Munin via the Mail-in-a-Box control panel
 	run_test(http_test, ["/admin/munin/", 401], 20, 30, 1)

From 26932ecb103b326069f3653e4420d770189c1460 Mon Sep 17 00:00:00 2001
From: Joshua Tauberer <jt@occams.info>
Date: Sun, 22 Aug 2021 16:38:49 -0400
Subject: [PATCH 4/6] Add a 'welcome' panel to the control panel and make it
 the default page instead of the status checks which take too long to load

Fixes #2014
---
 management/templates/index.html   |  6 ++++++
 management/templates/login.html   |  2 +-
 management/templates/welcome.html | 16 ++++++++++++++++
 3 files changed, 23 insertions(+), 1 deletion(-)
 create mode 100644 management/templates/welcome.html

diff --git a/management/templates/index.html b/management/templates/index.html
index 267f5dd6..492a953b 100644
--- a/management/templates/index.html
+++ b/management/templates/index.html
@@ -118,6 +118,10 @@
     </div>
 
     <div class="container">
+      <div id="panel_welcome" class="admin_panel">
+      {% include "welcome.html" %}
+      </div>
+
       <div id="panel_system_status" class="admin_panel">
       {% include "system-status.html" %}
       </div>
@@ -409,6 +413,8 @@ $(function() {
   // Recall what the user was last looking at.
   if (typeof localStorage != 'undefined' && localStorage.getItem("miab-cp-lastpanel")) {
     show_panel(localStorage.getItem("miab-cp-lastpanel"));
+  } else if (api_credentials[0] != "") {
+    show_panel('welcome');
   } else {
     show_panel('login');
   }
diff --git a/management/templates/login.html b/management/templates/login.html
index 3447d794..8ae79857 100644
--- a/management/templates/login.html
+++ b/management/templates/login.html
@@ -163,7 +163,7 @@ function do_login() {
       // Open the next panel the user wants to go to. Do this after the XHR response
       // is over so that we don't start a new XHR request while this one is finishing,
       // which confuses the loading indicator.
-      setTimeout(function() { show_panel(!switch_back_to_panel || switch_back_to_panel == "login" ? 'system_status' : switch_back_to_panel) }, 300);
+      setTimeout(function() { show_panel(!switch_back_to_panel || switch_back_to_panel == "login" ? 'welcome' : switch_back_to_panel) }, 300);
     }
   },
   undefined,
diff --git a/management/templates/welcome.html b/management/templates/welcome.html
new file mode 100644
index 00000000..124d2d28
--- /dev/null
+++ b/management/templates/welcome.html
@@ -0,0 +1,16 @@
+<style>
+  .title {
+    margin: 1em;
+    text-align: center;
+  }
+
+  .subtitle {
+    margin: 2em;
+    text-align: center;
+  }
+</style>
+
+<h1 class="title">{{hostname}}</h1>
+
+<p class="subtitle">Welcome to your Mail-in-a-Box control panel.</p>
+

From e5909a62870fc3a9d39a7ffe63a5264f9666ea79 Mon Sep 17 00:00:00 2001
From: Joshua Tauberer <jt@occams.info>
Date: Sun, 22 Aug 2021 16:40:07 -0400
Subject: [PATCH 5/6] Allow non-admin login to the control panel and show/hide
 menu items depending on the login state

* When logged out, no menu items are shown.
* When logged in, Log Out is shown.
* When logged in as an admin, the remaining menu items are also shown.
* When logged in as a non-admin, the mail and contacts/calendar instruction pages are shown.

Fixes #1987
---
 management/templates/index.html | 46 +++++++++++++++++++++------------
 management/templates/login.html | 28 +++++++++++++++++---
 management/templates/users.html |  6 ++---
 3 files changed, 57 insertions(+), 23 deletions(-)

diff --git a/management/templates/index.html b/management/templates/index.html
index 492a953b..081d527f 100644
--- a/management/templates/index.html
+++ b/management/templates/index.html
@@ -62,6 +62,9 @@
 	    ol li {
 	       margin-bottom: 1em;
 	    }
+
+            .if-logged-in { display: none; }
+            .if-logged-in-admin { display: none; }
         </style>
         <link rel="stylesheet" href="/admin/assets/bootstrap/css/bootstrap-theme.min.css">
     </head>
@@ -83,7 +86,7 @@
         </div>
         <div class="navbar-collapse collapse">
           <ul class="nav navbar-nav">
-            <li class="dropdown">
+            <li class="dropdown if-logged-in-admin">
               <a href="#" class="dropdown-toggle" data-toggle="dropdown">System <b class="caret"></b></a>
               <ul class="dropdown-menu">
                 <li><a href="#system_status" onclick="return show_panel(this);">Status Checks</a></li>
@@ -96,7 +99,8 @@
                 <li><a href="/admin/munin" target="_blank">Munin Monitoring</a></li>
               </ul>
             </li>
-            <li class="dropdown">
+            <li><a href="#mail-guide" onclick="return show_panel(this);" class="if-logged-in-not-admin">Mail</a></li>
+            <li class="dropdown if-logged-in-admin">
               <a href="#" class="dropdown-toggle" data-toggle="dropdown">Mail &amp; Users <b class="caret"></b></a>
               <ul class="dropdown-menu">
                 <li><a href="#mail-guide" onclick="return show_panel(this);">Instructions</a></li>
@@ -107,11 +111,11 @@
                 <li><a href="#mfa" onclick="return show_panel(this);">Two-Factor Authentication</a></li>
               </ul>
             </li>
-            <li><a href="#sync_guide" onclick="return show_panel(this);">Contacts/Calendar</a></li>
-            <li><a href="#web" onclick="return show_panel(this);">Web</a></li>
+            <li><a href="#sync_guide" onclick="return show_panel(this);" class="if-logged-in">Contacts/Calendar</a></li>
+            <li><a href="#web" onclick="return show_panel(this);" class="if-logged-in-admin">Web</a></li>
           </ul>
           <ul class="nav navbar-nav navbar-right">
-            <li><a href="#" onclick="do_logout(); return false;" style="color: white">Log out</a></li>
+            <li class="if-logged-in"><a href="#" onclick="do_logout(); return false;" style="color: white">Log out</a></li>
           </ul>
         </div><!--/.navbar-collapse -->
       </div>
@@ -302,7 +306,7 @@ function ajax_with_indicator(options) {
   return false; // handy when called from onclick
 }
 
-var api_credentials = ["", ""];
+var api_credentials = null;
 function api(url, method, data, callback, callback_error, headers) {
   // from http://www.webtoolkit.info/javascript-base64.html
   function base64encode(input) {
@@ -350,9 +354,10 @@ function api(url, method, data, callback, callback_error, headers) {
       // We don't store user credentials in a cookie to avoid the hassle of CSRF
       // attacks. The Authorization header only gets set in our AJAX calls triggered
       // by user actions.
-      xhr.setRequestHeader(
-        'Authorization',
-        'Basic ' + base64encode(api_credentials[0] + ':' + api_credentials[1]));
+      if (api_credentials)
+        xhr.setRequestHeader(
+          'Authorization',
+          'Basic ' + base64encode(api_credentials.username + ':' + api_credentials.session_key));
     },
     success: callback,
     error: callback_error || default_error,
@@ -375,7 +380,7 @@ function do_logout() {
   api("/logout", "POST");
 
   // Forget the token.
-  api_credentials = ["", ""];
+  api_credentials = null;
   if (typeof localStorage != 'undefined')
     localStorage.removeItem("miab-cp-credentials");
   if (typeof sessionStorage != 'undefined')
@@ -383,6 +388,9 @@ function do_logout() {
 
   // Return to the start.
   show_panel('login');
+
+  // Reset menus.
+  show_hide_menus();
 }
 
 function show_panel(panelid) {
@@ -405,15 +413,21 @@ function show_panel(panelid) {
 
 $(function() {
   // Recall saved user credentials.
-  if (typeof sessionStorage != 'undefined' && sessionStorage.getItem("miab-cp-credentials"))
-    api_credentials = sessionStorage.getItem("miab-cp-credentials").split(":");
-  else if (typeof localStorage != 'undefined' && localStorage.getItem("miab-cp-credentials"))
-    api_credentials = localStorage.getItem("miab-cp-credentials").split(":");
+  try {
+    if (typeof sessionStorage != 'undefined' && sessionStorage.getItem("miab-cp-credentials"))
+      api_credentials = JSON.parse(sessionStorage.getItem("miab-cp-credentials"));
+    else if (typeof localStorage != 'undefined' && localStorage.getItem("miab-cp-credentials"))
+      api_credentials = JSON.parse(localStorage.getItem("miab-cp-credentials"));
+  } catch (_) {
+  }
+
+  // Toggle menu state.
+  show_hide_menus();
 
   // Recall what the user was last looking at.
-  if (typeof localStorage != 'undefined' && localStorage.getItem("miab-cp-lastpanel")) {
+  if (api_credentials != null && typeof localStorage != 'undefined' && localStorage.getItem("miab-cp-lastpanel")) {
     show_panel(localStorage.getItem("miab-cp-lastpanel"));
-  } else if (api_credentials[0] != "") {
+  } else if (api_credentials != null) {
     show_panel('welcome');
   } else {
     show_panel('login');
diff --git a/management/templates/login.html b/management/templates/login.html
index 8ae79857..421c8845 100644
--- a/management/templates/login.html
+++ b/management/templates/login.html
@@ -102,7 +102,7 @@ function do_login() {
   }
 
   // Exchange the email address & password for an API key.
-  api_credentials = [$('#loginEmail').val(), $('#loginPassword').val()]
+  api_credentials = { username: $('#loginEmail').val(), session_key: $('#loginPassword').val() }
 
   api(
   "/login",
@@ -141,7 +141,9 @@ function do_login() {
       // Login succeeded.
 
       // Save the new credentials.
-      api_credentials = [response.email, response.api_key];
+      api_credentials = { username: response.email,
+                          session_key: response.api_key,
+                          privileges: response.privileges };
 
       // Try to wipe the username/password information.
       $('#loginEmail').val('');
@@ -152,14 +154,17 @@ function do_login() {
       // Remember the credentials.
       if (typeof localStorage != 'undefined' && typeof sessionStorage != 'undefined') {
         if ($('#loginRemember').val()) {
-          localStorage.setItem("miab-cp-credentials", api_credentials.join(":"));
+          localStorage.setItem("miab-cp-credentials", JSON.stringify(api_credentials));
           sessionStorage.removeItem("miab-cp-credentials");
         } else {
           localStorage.removeItem("miab-cp-credentials");
-          sessionStorage.setItem("miab-cp-credentials", api_credentials.join(":"));
+          sessionStorage.setItem("miab-cp-credentials", JSON.stringify(api_credentials));
         }
       }
 
+      // Toggle menus.
+      show_hide_menus();
+
       // Open the next panel the user wants to go to. Do this after the XHR response
       // is over so that we don't start a new XHR request while this one is finishing,
       // which confuses the loading indicator.
@@ -183,4 +188,19 @@ function show_login() {
     }
   });
 }
+
+function show_hide_menus() {
+  var is_logged_in = (api_credentials != null);
+  var privs = api_credentials ? api_credentials.privileges : [];
+  $('.if-logged-in').toggle(is_logged_in);
+  $('.if-logged-in-admin, .if-logged-in-not-admin').toggle(false);
+  if (is_logged_in) {
+    $('.if-logged-in-not-admin').toggle(true);
+    privs.forEach(function(priv) {
+      $('.if-logged-in-' + priv).toggle(true);
+      $('.if-logged-in-not-' + priv).toggle(false);
+    });
+  }
+  $('.if-not-logged-in').toggle(!is_logged_in);
+}
 </script>
diff --git a/management/templates/users.html b/management/templates/users.html
index 24adf4a1..2ad5ebdb 100644
--- a/management/templates/users.html
+++ b/management/templates/users.html
@@ -203,7 +203,7 @@ function users_set_password(elem) {
   var email = $(elem).parents('tr').attr('data-email');
 
   var yourpw = "";
-  if (api_credentials != null && email == api_credentials[0])
+  if (api_credentials != null && email == api_credentials.username)
     yourpw = "<p class='text-danger'>If you change your own password, you will be logged out of this control panel and will need to log in again.</p>";
 
   show_modal_confirm(
@@ -232,7 +232,7 @@ function users_remove(elem) {
   var email = $(elem).parents('tr').attr('data-email');
 
   // can't remove yourself
-  if (api_credentials != null && email == api_credentials[0]) {
+  if (api_credentials != null && email == api_credentials.username) {
     show_modal_error("Archive User", "You cannot archive your own account.");
     return;
   }
@@ -264,7 +264,7 @@ function mod_priv(elem, add_remove) {
   var priv = $(elem).parents('td').find('.name').text();
 
   // can't remove your own admin access
-  if (priv == "admin" && add_remove == "remove" && api_credentials != null && email == api_credentials[0]) {
+  if (priv == "admin" && add_remove == "remove" && api_credentials != null && email == api_credentials.username) {
     show_modal_error("Modify Privileges", "You cannot remove the admin privilege from yourself.");
     return;
   }

From 91079ab9347b7326a11c4011ce7c6cf8cf8b1491 Mon Sep 17 00:00:00 2001
From: mailinabox-contributor
 <90476861+mailinabox-contributor@users.noreply.github.com>
Date: Fri, 10 Sep 2021 15:12:41 -0500
Subject: [PATCH 6/6] add numeric flag value to DNSSEC DS status message
 (#2033)

Some registrars (e.g. Porkbun) accept Key Data when creating a DS RR,
but accept only a numeric flags value to indicate the key type (256 for KSK, 257 for ZSK).

https://datatracker.ietf.org/doc/html/rfc5910#section-4.3
---
 management/status_checks.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/management/status_checks.py b/management/status_checks.py
index e2d4b1a7..1e7223a5 100755
--- a/management/status_checks.py
+++ b/management/status_checks.py
@@ -646,7 +646,7 @@ def check_dnssec(domain, env, output, dns_zonefiles, is_checking_primary=False):
 		output.print_line("Option " + str(i+1) + ":")
 		output.print_line("----------")
 		output.print_line("Key Tag: " + ds_suggestion['keytag'])
-		output.print_line("Key Flags: KSK")
+		output.print_line("Key Flags: KSK (256)")
 		output.print_line("Algorithm: %s / %s" % (ds_suggestion['alg'], ds_suggestion['alg_name']))
 		output.print_line("Digest Type: %s / %s" % (ds_suggestion['digalg'], ds_suggestion['digalg_name']))
 		output.print_line("Digest: " + ds_suggestion['digest'])