From 3451dadde5af35e6a1a30aa7320d776fd2b69623 Mon Sep 17 00:00:00 2001 From: David Duque Date: Mon, 7 Nov 2022 21:07:37 +0000 Subject: [PATCH] Roundcube: Use Mail-in-a-Box admin API to drive password changes (#92) * Use Mail-in-a-Box driver We're using the user's own credentials to authenticate themselves. There are some issues if we release as-is: * Only usable if the user in question is an admin * Cannot be used if the user has 2FA enabled * daemon: Add selective gatekeeper * Allows us to give access to features for logged in, non-admin users * Allow non-admins to change their own password * Begin password management self service, frontend * Allow all users to enable 2FA * Password change front-end form * Self password change front-end functionality * Force logout after successful password change * Clear fields after successful password change, also fix error modal --- management/daemon.py | 230 +++++++++++----------- management/templates/index.html | 11 ++ management/templates/manage-password.html | 57 ++++++ setup/webmail.sh | 9 +- 4 files changed, 192 insertions(+), 115 deletions(-) create mode 100644 management/templates/manage-password.html diff --git a/management/daemon.py b/management/daemon.py index 936cca7..479af27 100755 --- a/management/daemon.py +++ b/management/daemon.py @@ -56,71 +56,70 @@ app = Flask(__name__, # Decorator to protect views that require a user with 'admin' privileges. +def authorized_personnel_only(admin = True): + def gatekeeper(viewfunc): -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 + # and an optional X-Auth-Token token. + error = None + privs = [] - @wraps(viewfunc) - def newview(*args, **kwargs): - # 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) - try: - email, privs = auth_service.authenticate(request, env) - except ValueError as e: - # 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) + # 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 - # Authentication failed. - error = str(e) + if not admin or "admin" in privs: + return viewfunc(*args, **kwargs) + else: + error = "You are not an administrator." + except ValueError as e: + # 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) - # 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 + # Authentication failed. + error = str(e) - # Call view func. - return viewfunc(*args, **kwargs) + # Not authorized. Return a 401 (send auth) and a prompt to authorize by default. + status = 401 + headers = { + 'WWW-Authenticate': + 'Basic realm="{0}"'.format(auth_service.auth_realm), + 'X-Reason': error, + } - if not error: - error = "You are not an administrator." + if request.headers.get('X-Requested-With') == 'XMLHttpRequest': + # Don't issue a 401 to an AJAX request because the user will + # be prompted for credentials, which is not helpful. + status = 403 + headers = None - # Not authorized. Return a 401 (send auth) and a prompt to authorize by default. - status = 401 - headers = { - 'WWW-Authenticate': - 'Basic realm="{0}"'.format(auth_service.auth_realm), - 'X-Reason': error, - } + if request.headers.get('Accept') in (None, "", "*/*"): + # Return plain text output. + return Response(error + "\n", + status=status, + mimetype='text/plain', + headers=headers) + else: + # Return JSON output. + return Response(json.dumps({ + "status": "error", + "reason": error, + }) + "\n", + status=status, + mimetype='application/json', + headers=headers) - if request.headers.get('X-Requested-With') == 'XMLHttpRequest': - # Don't issue a 401 to an AJAX request because the user will - # be prompted for credentials, which is not helpful. - status = 403 - headers = None + return newview - if request.headers.get('Accept') in (None, "", "*/*"): - # Return plain text output. - return Response(error + "\n", - status=status, - mimetype='text/plain', - headers=headers) - else: - # Return JSON output. - return Response(json.dumps({ - "status": "error", - "reason": error, - }) + "\n", - status=status, - mimetype='application/json', - headers=headers) - - return newview + return gatekeeper @app.errorhandler(401) @@ -213,7 +212,7 @@ def logout(): @app.route('/mail/users') -@authorized_personnel_only +@authorized_personnel_only() def mail_users(): if request.args.get("format", "") == "json": return json_response(get_mail_users_ex(env, with_archived=True)) @@ -222,7 +221,7 @@ def mail_users(): @app.route('/mail/users/add', methods=['POST']) -@authorized_personnel_only +@authorized_personnel_only() def mail_users_add(): quota = request.form.get('quota', get_default_quota(env)) try: @@ -234,7 +233,7 @@ def mail_users_add(): @app.route('/mail/users/quota', methods=['GET']) -@authorized_personnel_only +@authorized_personnel_only() def get_mail_users_quota(): email = request.values.get('email', '') quota = get_mail_quota(email, env) @@ -246,7 +245,7 @@ def get_mail_users_quota(): @app.route('/mail/users/quota', methods=['POST']) -@authorized_personnel_only +@authorized_personnel_only() def mail_users_quota(): try: return set_mail_quota(request.form.get('email', ''), @@ -256,8 +255,13 @@ def mail_users_quota(): @app.route('/mail/users/password', methods=['POST']) -@authorized_personnel_only +@authorized_personnel_only(admin = False) def mail_users_password(): + if "admin" not in request.user_privs: + # Non-admins can only change their own password. + if request.form.get('email', '') != request.user_email: + return ("You are not an administrator; you can only change your own password!", 403) + try: return set_mail_password(request.form.get('email', ''), request.form.get('password', ''), env) @@ -266,13 +270,13 @@ def mail_users_password(): @app.route('/mail/users/remove', methods=['POST']) -@authorized_personnel_only +@authorized_personnel_only() def mail_users_remove(): return remove_mail_user(request.form.get('email', ''), env) @app.route('/mail/users/privileges') -@authorized_personnel_only +@authorized_personnel_only() def mail_user_privs(): privs = get_mail_user_privileges(request.args.get('email', ''), env) if isinstance(privs, tuple): @@ -281,7 +285,7 @@ def mail_user_privs(): @app.route('/mail/users/privileges/add', methods=['POST']) -@authorized_personnel_only +@authorized_personnel_only() def mail_user_privs_add(): return add_remove_mail_user_privilege(request.form.get('email', ''), request.form.get('privilege', ''), @@ -289,7 +293,7 @@ def mail_user_privs_add(): @app.route('/mail/users/privileges/remove', methods=['POST']) -@authorized_personnel_only +@authorized_personnel_only() def mail_user_privs_remove(): return add_remove_mail_user_privilege(request.form.get('email', ''), request.form.get('privilege', ''), @@ -297,7 +301,7 @@ def mail_user_privs_remove(): @app.route('/mail/aliases') -@authorized_personnel_only +@authorized_personnel_only() def mail_aliases(): if request.args.get("format", "") == "json": return json_response(get_mail_aliases_ex(env)) @@ -308,7 +312,7 @@ def mail_aliases(): @app.route('/mail/aliases/add', methods=['POST']) -@authorized_personnel_only +@authorized_personnel_only() def mail_aliases_add(): return add_mail_alias(request.form.get('address', ''), request.form.get('forwards_to', ''), @@ -319,13 +323,13 @@ def mail_aliases_add(): @app.route('/mail/aliases/remove', methods=['POST']) -@authorized_personnel_only +@authorized_personnel_only() def mail_aliases_remove(): return remove_mail_alias(request.form.get('address', ''), env) @app.route('/mail/domains') -@authorized_personnel_only +@authorized_personnel_only() def mail_domains(): return "".join(x + "\n" for x in get_mail_domains(env)) @@ -334,14 +338,14 @@ def mail_domains(): @app.route('/dns/zones') -@authorized_personnel_only +@authorized_personnel_only() def dns_zones(): from dns_update import get_dns_zones return json_response([z[0] for z in get_dns_zones(env)]) @app.route('/dns/update', methods=['POST']) -@authorized_personnel_only +@authorized_personnel_only() def dns_update(): from dns_update import do_dns_update try: @@ -351,7 +355,7 @@ def dns_update(): @app.route('/dns/secondary-nameserver') -@authorized_personnel_only +@authorized_personnel_only() def dns_get_secondary_nameserver(): from dns_update import get_custom_dns_config, get_secondary_dns return json_response({ @@ -361,7 +365,7 @@ def dns_get_secondary_nameserver(): @app.route('/dns/secondary-nameserver', methods=['POST']) -@authorized_personnel_only +@authorized_personnel_only() def dns_set_secondary_nameserver(): from dns_update import set_secondary_dns try: @@ -375,7 +379,7 @@ def dns_set_secondary_nameserver(): @app.route('/dns/custom') -@authorized_personnel_only +@authorized_personnel_only() def dns_get_records(qname=None, rtype=None): # Get the current set of custom DNS records. from dns_update import get_custom_dns_config, get_dns_zones @@ -431,7 +435,7 @@ def dns_get_records(qname=None, rtype=None): @app.route('/dns/custom/', methods=['GET', 'POST', 'PUT', 'DELETE']) @app.route('/dns/custom//', methods=['GET', 'POST', 'PUT', 'DELETE']) -@authorized_personnel_only +@authorized_personnel_only() def dns_set_record(qname, rtype="A"): from dns_update import do_dns_update, set_custom_dns_record try: @@ -498,14 +502,14 @@ def dns_set_record(qname, rtype="A"): @app.route('/dns/dump') -@authorized_personnel_only +@authorized_personnel_only() def dns_get_dump(): from dns_update import build_recommended_dns return json_response(build_recommended_dns(env)) @app.route('/dns/zonefile/') -@authorized_personnel_only +@authorized_personnel_only() def dns_get_zonefile(zone): from dns_update import get_dns_zonefile return Response(get_dns_zonefile(zone, env), @@ -517,7 +521,7 @@ def dns_get_zonefile(zone): @app.route('/ssl/status') -@authorized_personnel_only +@authorized_personnel_only() def ssl_get_status(): from ssl_certificates import get_certificates_to_provision from web_update import get_web_domains_info, get_web_domains @@ -557,7 +561,7 @@ def ssl_get_status(): @app.route('/ssl/csr/', methods=['POST']) -@authorized_personnel_only +@authorized_personnel_only() def ssl_get_csr(domain): from ssl_certificates import create_csr ssl_private_key = os.path.join( @@ -567,7 +571,7 @@ def ssl_get_csr(domain): @app.route('/ssl/install', methods=['POST']) -@authorized_personnel_only +@authorized_personnel_only() def ssl_install_cert(): from web_update import get_web_domains from ssl_certificates import install_cert @@ -580,7 +584,7 @@ def ssl_install_cert(): @app.route('/ssl/provision', methods=['POST']) -@authorized_personnel_only +@authorized_personnel_only() def ssl_provision_certs(): from ssl_certificates import provision_certificates requests = provision_certificates(env, limit_domains=None) @@ -591,7 +595,7 @@ def ssl_provision_certs(): @app.route('/mfa/status', methods=['POST']) -@authorized_personnel_only +@authorized_personnel_only(admin = False) 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 @@ -599,6 +603,9 @@ def mfa_get_status(): # only provision for themselves. # user field if given, otherwise the user making the request email = request.form.get('user', request.user_email) + if "admin" not in request.user_privs and email != request.user_email: + return ("You are not an administrator; you can only view your own MFA status!", 403) + try: resp = {"enabled_mfa": get_public_mfa_state(email, env)} if email == request.user_email: @@ -609,7 +616,7 @@ def mfa_get_status(): @app.route('/mfa/totp/enable', methods=['POST']) -@authorized_personnel_only +@authorized_personnel_only(admin = False) def totp_post_enable(): secret = request.form.get('secret') token = request.form.get('token') @@ -625,13 +632,16 @@ def totp_post_enable(): @app.route('/mfa/disable', methods=['POST']) -@authorized_personnel_only +@authorized_personnel_only(admin = False) 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. # user field if given, otherwise the user making the request email = request.form.get('user', request.user_email) + if "admin" not in request.user_privs and email != request.user_email: + return ("You are not an administrator; you can only view your own MFA status!", 403) + try: result = disable_mfa(email, request.form.get('mfa-id') or None, @@ -648,14 +658,14 @@ def totp_post_disable(): @app.route('/web/domains') -@authorized_personnel_only +@authorized_personnel_only() def web_get_domains(): from web_update import get_web_domains_info return json_response(get_web_domains_info(env)) @app.route('/web/update', methods=['POST']) -@authorized_personnel_only +@authorized_personnel_only() def web_update(): from web_update import do_web_update try: @@ -668,7 +678,7 @@ def web_update(): @app.route('/system/version', methods=["GET"]) -@authorized_personnel_only +@authorized_personnel_only() def system_version(): from status_checks import what_version_is_this try: @@ -678,7 +688,7 @@ def system_version(): @app.route('/system/latest-upstream-version', methods=["POST"]) -@authorized_personnel_only +@authorized_personnel_only() def system_latest_upstream_version(): from status_checks import get_latest_miab_version try: @@ -688,7 +698,7 @@ def system_latest_upstream_version(): @app.route('/system/status', methods=["POST"]) -@authorized_personnel_only +@authorized_personnel_only() def system_status(): from status_checks import run_checks @@ -736,7 +746,7 @@ def system_status(): @app.route('/system/updates') -@authorized_personnel_only +@authorized_personnel_only() def show_updates(): from status_checks import list_apt_updates return "".join("%s (%s)\n" % (p["package"], p["version"]) @@ -744,7 +754,7 @@ def show_updates(): @app.route('/system/update-packages', methods=["POST"]) -@authorized_personnel_only +@authorized_personnel_only() def do_updates(): utils.shell("check_call", ["/usr/bin/apt-get", "-qq", "update"]) return utils.shell("check_output", ["/usr/bin/apt-get", "-y", "upgrade"], @@ -752,7 +762,7 @@ def do_updates(): @app.route('/system/reboot', methods=["GET"]) -@authorized_personnel_only +@authorized_personnel_only() def needs_reboot(): from status_checks import is_reboot_needed_due_to_package_installation if is_reboot_needed_due_to_package_installation(): @@ -762,7 +772,7 @@ def needs_reboot(): @app.route('/system/reboot', methods=["POST"]) -@authorized_personnel_only +@authorized_personnel_only() def do_reboot(): # To keep the attack surface low, we don't allow a remote reboot if one isn't necessary. from status_checks import is_reboot_needed_due_to_package_installation @@ -774,7 +784,7 @@ def do_reboot(): @app.route('/system/backup/status') -@authorized_personnel_only +@authorized_personnel_only() def backup_status(): from backup import backup_status try: @@ -784,14 +794,14 @@ def backup_status(): @app.route('/system/backup/config', methods=["GET"]) -@authorized_personnel_only +@authorized_personnel_only() def backup_get_custom(): from backup import get_backup_config return json_response(get_backup_config(env, for_ui=True)) @app.route('/system/backup/config', methods=["POST"]) -@authorized_personnel_only +@authorized_personnel_only() def backup_set_custom(): from backup import backup_set_custom return json_response( @@ -803,7 +813,7 @@ def backup_set_custom(): @app.route('/system/backup/new', methods=["POST"]) -@authorized_personnel_only +@authorized_personnel_only() def backup_new(): from backup import perform_backup, get_backup_config @@ -817,14 +827,14 @@ def backup_new(): @app.route('/system/privacy', methods=["GET"]) -@authorized_personnel_only +@authorized_personnel_only() def privacy_status_get(): config = utils.load_settings(env) return json_response(config.get("privacy", True)) @app.route('/system/privacy', methods=["POST"]) -@authorized_personnel_only +@authorized_personnel_only() def privacy_status_set(): config = utils.load_settings(env) config["privacy"] = (request.form.get('value') == "private") @@ -833,7 +843,7 @@ def privacy_status_set(): @app.route('/system/smtp/relay', methods=["GET"]) -@authorized_personnel_only +@authorized_personnel_only() def smtp_relay_get(): config = utils.load_settings(env) @@ -864,7 +874,7 @@ def smtp_relay_get(): @app.route('/system/smtp/relay', methods=["POST"]) -@authorized_personnel_only +@authorized_personnel_only() def smtp_relay_set(): from editconf import edit_conf from os import chmod @@ -995,7 +1005,7 @@ def smtp_relay_set(): @app.route('/system/pgp/', methods=["GET"]) -@authorized_personnel_only +@authorized_personnel_only() def get_keys(): from pgp import get_daemon_key, get_imported_keys, key_representation return { @@ -1005,7 +1015,7 @@ def get_keys(): @app.route('/system/pgp/', methods=["GET"]) -@authorized_personnel_only +@authorized_personnel_only() def get_key(fpr): from pgp import get_key, key_representation k = get_key(fpr) @@ -1015,7 +1025,7 @@ def get_key(fpr): @app.route('/system/pgp/', methods=["DELETE"]) -@authorized_personnel_only +@authorized_personnel_only() def delete_key(fpr): from pgp import delete_key from wkd import parse_wkd_list, build_wkd @@ -1030,7 +1040,7 @@ def delete_key(fpr): @app.route('/system/pgp//export', methods=["GET"]) -@authorized_personnel_only +@authorized_personnel_only() def export_key(fpr): from pgp import export_key exp = export_key(fpr) @@ -1040,7 +1050,7 @@ def export_key(fpr): @app.route('/system/pgp/import', methods=["POST"]) -@authorized_personnel_only +@authorized_personnel_only() def import_key(): from pgp import import_key from wkd import build_wkd @@ -1065,7 +1075,7 @@ def import_key(): @app.route('/system/pgp/wkd', methods=["GET"]) -@authorized_personnel_only +@authorized_personnel_only() def get_wkd_status(): from pgp import get_daemon_key, get_imported_keys, key_representation from wkd import get_user_fpr_maps, get_wkd_config @@ -1099,7 +1109,7 @@ def get_wkd_status(): @app.route('/system/pgp/wkd', methods=["POST"]) -@authorized_personnel_only +@authorized_personnel_only() def update_wkd(): from wkd import update_wkd_config, build_wkd update_wkd_config(request.form) @@ -1108,7 +1118,7 @@ def update_wkd(): @app.route('/system/default-quota', methods=["GET"]) -@authorized_personnel_only +@authorized_personnel_only() def default_quota_get(): if request.values.get('text'): return get_default_quota(env) @@ -1119,7 +1129,7 @@ def default_quota_get(): @app.route('/system/default-quota', methods=["POST"]) -@authorized_personnel_only +@authorized_personnel_only() def default_quota_set(): config = utils.load_settings(env) try: @@ -1137,7 +1147,7 @@ def default_quota_set(): @app.route('/munin/') -@authorized_personnel_only +@authorized_personnel_only() 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 diff --git a/management/templates/index.html b/management/templates/index.html index 57e676e..caf12c3 100644 --- a/management/templates/index.html +++ b/management/templates/index.html @@ -135,6 +135,13 @@ Monitoring +