diff --git a/CHANGELOG.md b/CHANGELOG.md index e22592f..7dd0b9e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ Control panel: * After logging in, the default page is now a fast-loading welcome page rather than the slow-loading system status checks page. * The backup retention period option now displays for B2 backup targets. * The DNSSEC DS record recommendations are cleaned up and now recommend changing records that use SHA1. +* The Munin monitoring pages no longer require a separate HTTP basic authentication login and can be used if two-factor authentication is turned on. * Control panel logins are now tied to a session backend that allows true logouts (rather than an encrypted cookie). * Failed logins no longer directly reveal whether the email address corresponds to a user account. * Browser dark mode now inverts the color scheme. diff --git a/management/auth.py b/management/auth.py index 38c15e9..0a88c45 100644 --- a/management/auth.py +++ b/management/auth.py @@ -72,14 +72,9 @@ class AuthService: 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: + if self.get_session(username, password, "login", env) 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] @@ -158,5 +153,14 @@ class AuthService: self.sessions[token] = { "email": username, "password_token": self.create_user_password_state_token(username, env), + "type": type, } return token + + def get_session(self, user_email, session_key, session_type, env): + if session_key not in self.sessions: return None + session = self.sessions[session_key] + if session_type == "login" and session["email"] != user_email: return None + if session["type"] != session_type: return None + if session["password_token"] != self.create_user_password_state_token(session["email"], env): return None + return session diff --git a/management/daemon.py b/management/daemon.py index ca89177..280efec 100755 --- a/management/daemon.py +++ b/management/daemon.py @@ -654,16 +654,42 @@ def privacy_status_set(): # MUNIN @app.route('/munin/') -@app.route('/munin/') @authorized_personnel_only -def munin(filename=""): - # Checks administrative access (@authorized_personnel_only) and then just proxies - # the request to static files. +def munin_start(): + # Munin pages, static images, and dynamically generated images are served + # outside of the AJAX API. We'll start with a 'start' API that sets a cookie + # that subsequent requests will read for authorization. (We don't use cookies + # for the API to avoid CSRF vulnerabilities.) + response = make_response("OK") + response.set_cookie("session", auth_service.create_session_key(request.user_email, env, type='cookie'), + max_age=60*30, secure=True, httponly=True, samesite="Strict") # 30 minute duration + return response + +def check_request_cookie_for_admin_access(): + session = auth_service.get_session(None, request.cookies.get("session", ""), "cookie", env) + if not session: return False + privs = get_mail_user_privileges(session["email"], env) + if not isinstance(privs, list): return False + if "admin" not in privs: return False + return True + +def authorized_personnel_only_via_cookie(f): + @wraps(f) + def g(*args, **kwargs): + if not check_request_cookie_for_admin_access(): + return Response("Unauthorized", status=403, mimetype='text/plain', headers={}) + return f(*args, **kwargs) + return g + +@app.route('/munin/') +@authorized_personnel_only_via_cookie +def munin_static_file(filename=""): + # Proxy the request to static files. if filename == "": filename = "index.html" return send_from_directory("/var/cache/munin/www", filename) @app.route('/munin/cgi-graph/') -@authorized_personnel_only +@authorized_personnel_only_via_cookie def munin_cgi(filename): """ Relay munin cgi dynazoom requests /usr/lib/munin/cgi/munin-cgi-graph is a perl cgi script in the munin package diff --git a/management/templates/index.html b/management/templates/index.html index 115693a..f9c87f2 100644 --- a/management/templates/index.html +++ b/management/templates/index.html @@ -124,7 +124,7 @@
  • Custom DNS
  • External DNS
  • -
  • Munin Monitoring
  • +
  • Munin Monitoring
  • Mail
  • @@ -202,6 +202,10 @@ {% include "ssl.html" %} +
    + {% include "munin.html" %} +
    +