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
This commit is contained in:
David Duque 2022-11-07 21:07:37 +00:00 committed by GitHub
parent b961a2b74a
commit 3451dadde5
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
4 changed files with 192 additions and 115 deletions

View file

@ -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/<qname>', methods=['GET', 'POST', 'PUT', 'DELETE'])
@app.route('/dns/custom/<qname>/<rtype>',
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/<zone>')
@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/<domain>', 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/<fpr>', 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/<fpr>', 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/<fpr>/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

View file

@ -135,6 +135,13 @@
Monitoring</a></li>
</ul>
</li>
<li class="nav-item me-1 me-xl-4 dropdown if-logged-in-not-admin">
<button class="btn dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false">Your Account</button>
<ul class="dropdown-menu">
<li><a class="dropdown-item" href="#manage-password" onclick="return show_panel(this);">Manage Password</a></li>
<li><a class="dropdown-item" href="#mfa" onclick="return show_panel(this);">Two-Factor Authentication</a></li>
</ul>
</li>
<li class="nav-item me-1 me-xl-4 btn if-logged-in-not-admin" type="button" href="#mail-guide"
onclick="return show_panel(this);">
Mail Guide
@ -198,6 +205,10 @@
{% include "wkd.html" %}
</div>
<div id="panel_manage-password" class="admin_panel">
{% include "manage-password.html" %}
</div>
<div id="panel_mfa" class="admin_panel">
{% include "mfa.html" %}
</div>

View file

@ -0,0 +1,57 @@
<div>
<h2>Manage Password</h2>
<p>Here you can change your account password. The new password is then valid for both this panel and your email.</p>
<p>If you have client emails configured, you'll then need to update the configuration with the new password. See the <a href="#mail-guide" onclick="return show_panel(this);">Mail Guide</a> for more information about this.</p>
<form class="form-horizontal" role="form" onsubmit="set_password_self(); return false;">
<div class="col-lg-10 col-xl-8 mb-3">
<div class="input-group">
<label for="manage-password-new" class="input-group-text col-3">New Password</label>
<input type="password" placeholder="password" class="form-control" id="manage-password-new">
</div>
</div>
<div class="col-lg-10 col-xl-8 mb-3">
<div class="input-group">
<label for="manage-password-confirm" class="input-group-text col-3">Confirm Password</label>
<input type="password" placeholder="password" class="form-control" id="manage-password-confirm">
</div>
</div>
<div class="mt-3">
<button id="manage-password-submit" type="submit" class="btn btn-primary">Save</button>
</div>
<small>After changing your password, you'll be logged out from the account and will need to log in again.</small>
</form>
</div>
<script>
function set_password_self() {
if ($('#manage-password-new').val() !== $('#manage-password-confirm').val()) {
show_modal_error("Set Password", 'Passwords do not match!');
return;
}
let password = $('#manage-password-new').val()
api(
"/mail/users/password",
"POST",
{
email: api_credentials.username,
password: password
},
function (r) {
// Responses are multiple lines of pre-formatted text.
show_modal_error("Set Password", $("<pre/>").text(r), () => {
do_logout()
$('#manage-password-new').val("")
$('#manage-password-confirm').val("")
});
},
function (r) {
show_modal_error("Set Password", r);
}
);
}
</script>

View file

@ -212,12 +212,11 @@ cp ${RCM_PLUGIN_DIR}/password/config.inc.php.dist \
${RCM_PLUGIN_DIR}/password/config.inc.php
management/editconf.py ${RCM_PLUGIN_DIR}/password/config.inc.php -c "//" \
"\$config['password_driver'] = 'miab';" \
"\$config['password_minimum_length'] = 8;" \
"\$config['password_db_dsn'] = 'sqlite:///$STORAGE_ROOT/mail/users.sqlite';" \
"\$config['password_query'] = 'UPDATE users SET password=%P WHERE email=%u';" \
"\$config['password_algorithm'] = 'sha512-crypt';" \
"\$config['password_algorithm_prefix'] = '{SHA512-CRYPT}';" \
"\$config['password_dovecotpw_with_method'] = false;"
"\$config['password_miab_url'] = 'http://127.0.0.1:10222/';" \
"\$config['password_miab_user'] = '';" \
"\$config['password_miab_pass'] = '';"
# so PHP can use doveadm, for the password changing plugin
usermod -a -G dovecot www-data