1
0
mirror of https://github.com/mail-in-a-box/mailinabox.git synced 2026-03-13 17:17:23 +01:00

Compare commits

...

2 Commits

Author SHA1 Message Date
Joshua Tauberer
d4428e1c67 WebAuthn MFA for the control panel 2021-10-18 20:15:08 -04:00
Joshua Tauberer
30f067bc72 Reorganize TOTP in the control panel templates to allow adding multiple devices and disabling individual devices 2021-10-18 20:14:25 -04:00
5 changed files with 175 additions and 69 deletions

View File

@@ -1740,7 +1740,7 @@ paths:
text/html:
schema:
type: string
/mfa/totp/enable:
/mfa/enable/totp:
post:
tags:
- MFA

View File

@@ -21,7 +21,7 @@ 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
from mfa import get_public_mfa_state, provision_totp, validate_totp_secret, enable_mfa, disable_mfa
from mfa import get_public_mfa_state, provision_totp, provision_webauthn, validate_totp_secret, enable_mfa, disable_mfa
env = utils.load_environment()
@@ -468,7 +468,7 @@ def ssl_provision_certs():
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
# field. But we don't include provisioning info since a user can
# field. But we don't always 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:
@@ -478,14 +478,15 @@ def mfa_get_status():
if email == request.user_email:
resp.update({
"new_mfa": {
"totp": provision_totp(email, env)
"totp": provision_totp(email, env),
"webauthn": provision_webauthn(email, env)
}
})
except ValueError as e:
return (str(e), 400)
return json_response(resp)
@app.route('/mfa/totp/enable', methods=['POST'])
@app.route('/mfa/enable/totp', methods=['POST'])
@authorized_personnel_only
def totp_post_enable():
secret = request.form.get('secret')
@@ -495,7 +496,20 @@ def totp_post_enable():
return ("Bad Input", 400)
try:
validate_totp_secret(secret)
enable_mfa(request.user_email, "totp", secret, token, label, env)
enable_mfa(request.user_email, "totp", env, secret, token, label)
except ValueError as e:
return (str(e), 400)
return "OK"
@app.route('/mfa/enable/webauthn', methods=['POST'])
@authorized_personnel_only
def webauthn_post_enable():
attestationObject = request.form.get('attestationObject')
clientDataJSON = request.form.get('clientDataJSON')
if type(attestationObject) != str or type(clientDataJSON) != str:
return ("Bad Input", 400)
try:
enable_mfa(request.user_email, "webauthn", env, attestationObject, clientDataJSON)
except ValueError as e:
return (str(e), 400)
return "OK"

View File

@@ -1,9 +1,12 @@
import base64
import hmac
import io
import json
import os
import pyotp
import qrcode
import pywarp
import pywarp.backends
from mailconfig import open_database
@@ -29,25 +32,44 @@ def get_public_mfa_state(email, env):
]
def get_hash_mfa_state(email, env):
mfa_state = get_mfa_state(email, env)
return [
{ "id": s["id"], "type": s["type"], "secret": s["secret"] }
for s in mfa_state
]
# Get the current MFA credential secrets from which we form a hash
# so that we can reset user logins when any authentication information
# changes.
mfa_state = []
for s in get_mfa_state(email, env):
# Add TOTP id and secret to the state.
# Skip WebAuthn state if it's just a challenge.
if s["type"] == "webauthn":
try:
# Get the credential and only include it (not challenges) in the state.
s["secret"] = json.loads(s["secret"])["cred_pub_key"]
except:
# Skip this one --- there is no cred_pub_key.
continue
mfa_state.append({ "id": s["id"], "type": s["type"], "secret": s["secret"] })
return mfa_state
def enable_mfa(email, type, secret, token, label, env):
def enable_mfa(email, type, env, *args):
if type == "totp":
secret, token, label = args
validate_totp_secret(secret)
# Sanity check with the provide current token.
totp = pyotp.TOTP(secret)
if not totp.verify(token, valid_window=1):
raise ValueError("Invalid token.")
conn, c = open_database(env, with_connection=True)
c.execute('INSERT INTO mfa (user_id, type, secret, label) VALUES (?, ?, ?, ?)', (get_user_id(email, c), type, secret, label))
conn.commit()
elif type == "webauthn":
attestationObject, clientDataJSON = args
rp = pywarp.RelyingPartyManager(
get_relying_party_name(env),
rp_id=env["PRIMARY_HOSTNAME"], # must match hostname the control panel is served from
credential_storage_backend=WebauthnStorageBackend(env))
rp.register(attestation_object=base64.b64decode(attestationObject), client_data_json=base64.b64decode(clientDataJSON), email=email.encode("utf8")) # encoding of email is a little funky here, pywarp calls .decode() with no args?
else:
raise ValueError("Invalid MFA type.")
conn, c = open_database(env, with_connection=True)
c.execute('INSERT INTO mfa (user_id, type, secret, label) VALUES (?, ?, ?, ?)', (get_user_id(email, c), type, secret, label))
conn.commit()
def set_mru_token(email, mfa_id, token, env):
conn, c = open_database(env, with_connection=True)
@@ -71,6 +93,9 @@ def validate_totp_secret(secret):
if len(secret) != 32:
raise ValueError("Secret should be a 32 characters base32 string")
def get_relying_party_name(env):
return env["PRIMARY_HOSTNAME"] + " Mail-in-a-Box Control Panel"
def provision_totp(email, env):
# Make a new secret.
secret = base64.b32encode(os.urandom(20)).decode('utf-8')
@@ -79,7 +104,7 @@ def provision_totp(email, env):
# Make a URI that we encode within a QR code.
uri = pyotp.TOTP(secret).provisioning_uri(
name=email,
issuer_name=env["PRIMARY_HOSTNAME"] + " Mail-in-a-Box Control Panel"
issuer_name=get_relying_party_name(env)
)
# Generate a QR code as a base64-encode PNG image.
@@ -94,6 +119,55 @@ def provision_totp(email, env):
"qr_code_base64": png_b64
}
class WebauthnStorageBackend(pywarp.backends.CredentialStorageBackend):
def __init__(self, env):
self.env = env
def get_record(self, email, conn=None, c=None):
# Get an existing record and parse the 'secret' column as JSON.
if conn is None: conn, c = open_database(self.env, with_connection=True)
c.execute('SELECT secret FROM mfa WHERE user_id=? AND type="webauthn"', (get_user_id(email, c),))
config = c.fetchone()
if config:
try:
return json.loads(config[0])
except:
pass
return { }
def update_record(self, email, fields):
# Update the webauthn record in the database for this user by
# merging the fields with the existing fields in the database.
conn, c = open_database(self.env, with_connection=True)
config = self.get_record(email, conn=conn, c=c)
if config:
# Merge and update.
config.update(fields)
config = json.dumps(config)
c.execute('UPDATE mfa SET secret=? WHERE user_id=? AND type="webauthn"', (config, get_user_id(email, c),))
conn.commit()
return
# Either there's no existing webauthn record or it's corrupted. Delete any existing record.
# Then add a new record.
c.execute('DELETE FROM mfa WHERE user_id=? AND type="webauthn"', (get_user_id(email, c),))
c.execute('INSERT INTO mfa (user_id, type, secret, label) VALUES (?, ?, ?, ?)', (
get_user_id(email, c), "webauthn",
json.dumps(fields),
"WebAuthn"))
conn.commit()
def save_challenge_for_user(self, email, challenge, type):
self.update_record(email, { type + "challenge": base64.b64encode(challenge).decode("ascii") })
def get_challenge_for_user(self, email, type):
challenge = self.get_record(email).get(type + "challenge")
if challenge: challenge = base64.b64decode(challenge.encode("ascii"))
return challenge
def provision_webauthn(email, env):
rp = pywarp.RelyingPartyManager(
get_relying_party_name(env),
rp_id=env["PRIMARY_HOSTNAME"], # must match hostname the control panel is served from
credential_storage_backend=WebauthnStorageBackend(env))
return rp.get_registration_options(email=email)
def validate_auth_mfa(email, request, env):
# Validates that a login request satisfies any MFA modes
# that have been enabled for the user's account. Returns

View File

@@ -1,34 +1,10 @@
<style>
.twofactor #totp-setup,
.twofactor #disable-2fa,
.twofactor #output-2fa {
display: none;
}
.twofactor.loaded .loading-indicator {
display: none;
}
.twofactor.disabled #disable-2fa,
.twofactor.enabled #totp-setup {
display: none;
}
.twofactor.disabled #totp-setup,
.twofactor.enabled #disable-2fa {
display: block;
}
.twofactor #totp-setup-qr img {
display: block;
width: 256px;
max-width: 100%;
height: auto;
}
.twofactor #output-2fa.visible {
display: block;
}
</style>
<h2>Two-Factor Authentication</h2>
@@ -51,10 +27,11 @@ and ensure every administrator account for this control panel does the same.</st
</div>
<div class="twofactor">
<div class="loading-indicator">Loading...</div>
<div id="mfa-devices">
</div>
<form id="totp-setup">
<h3>Setup Instructions</h3>
<form id="totp-setup" style="display: none">
<h3>Add a TOTP Device</h3>
<div class="form-group">
<p>1. Install <a href="https://freeotp.github.io/">FreeOTP</a> or <a href="https://www.pcworld.com/article/3225913/what-is-two-factor-authentication-and-which-2fa-apps-are-best.html">any
@@ -85,24 +62,32 @@ and ensure every administrator account for this control panel does the same.</st
</div>
</form>
<form id="disable-2fa">
<div class="form-group">
<p>Two-factor authentication is active for your account<span id="mfa-device-label"></span>.</p>
<p>You will have to log into the admin panel again after disabling two-factor authentication.</p>
</div>
<div class="form-group">
<button type="submit" class="btn btn-danger">Disable Two-Factor Authentication</button>
</div>
<form id="webauthn-setup" style="display: none">
<h3>Add a WebAuthn Device</h3>
<p>If you have a WebAuthn device such as a YubiKey, plug it in and click Add WebAuthn Device.</p>
<button type="submit" class="btn" onclick="return do_enable_webauthn()">Add WebAuthn Device</button>
</form>
<div id="output-2fa" class="panel panel-danger">
<div id="webauthn-setup" style="display: none">
</div>
<div id="output-2fa" class="panel panel-danger hidden">
<div class="panel-body"></div>
</div>
<div id="mfa-device-templates" style="display: none">
<form class="totp" style="margin: 1em 0; border: 1px solid #AAA; padding: 10px;">
<p>Two-factor authentication is active for your account<span class="mfa-device-label"></span>.</p>
<div class="form-group">
<button type="submit" class="btn btn-danger">Disable TOTP Device</button>
</div>
<p style="margin-bottom: 0">You will have to log into the admin panel again after disabling two-factor authentication.</p>
</form>
</div>
</div>
<script>
var el = {
disableForm: document.getElementById('disable-2fa'),
output: document.getElementById('output-2fa'),
totpSetupForm: document.getElementById('totp-setup'),
totpSetupToken: document.getElementById('totp-setup-token'),
@@ -110,6 +95,7 @@ and ensure every administrator account for this control panel does the same.</st
totpSetupLabel: document.getElementById('totp-setup-label'),
totpQr: document.getElementById('totp-setup-qr'),
totpSetupSubmit: document.querySelector('#totp-setup-submit'),
webauthnSetupForm: document.getElementById('webauthn-setup'),
wrapper: document.querySelector('.twofactor')
}
@@ -130,6 +116,8 @@ and ensure every administrator account for this control panel does the same.</st
}
function render_totp_setup(provisioned_totp) {
$(el.totpSetupForm).show();
var img = document.createElement('img');
img.src = "data:image/png;base64," + provisioned_totp.qr_code_base64;
@@ -147,38 +135,50 @@ and ensure every administrator account for this control panel does the same.</st
el.wrapper.classList.add('disabled');
}
function arrayBufferToBase64(a) { return btoa(String.fromCharCode(...new Uint8Array(a))); }
function base64ToArrayBuffer(b) { return Uint8Array.from(atob(b), c => c.charCodeAt(0)); }
function render_webauthn_setup(provisioning) {
$(el.webauthnSetupForm).show();
provisioning.challenge = base64ToArrayBuffer(provisioning.challenge);
provisioning.user.id = new TextEncoder().encode(provisioning.user.name);
window.mailinabix_mfa_webauthn_provision = provisioning;
}
function render_disable(mfa) {
el.disableForm.addEventListener('submit', do_disable);
el.wrapper.classList.add('enabled');
var panel = $('#mfa-device-templates .' + mfa.type).clone();
$('#mfa-devices').append(panel);
panel.attr('data-mfa-id', mfa.id);
panel.on('submit', do_disable);
if (mfa.label)
$("#mfa-device-label").text(" on device '" + mfa.label + "'");
panel.find(".mfa-device-label").text(" on device '" + mfa.label + "'");
}
function hide_error() {
el.output.querySelector('.panel-body').innerHTML = '';
el.output.classList.remove('visible');
el.output.classList.add('hidden');
}
function render_error(msg) {
el.output.querySelector('.panel-body').innerHTML = msg;
el.output.classList.add('visible');
el.output.classList.remove('hidden');
}
function reset_view() {
el.wrapper.classList.remove('loaded', 'disabled', 'enabled');
el.disableForm.removeEventListener('submit', do_disable);
$('#mfa-devices > *').remove();
hide_error();
$(el.totpSetupForm).hide();
el.totpSetupForm.reset();
el.totpSetupForm.removeEventListener('submit', do_enable_totp);
el.totpSetupSecret.setAttribute('value', '');
el.totpSetupToken.removeEventListener('input', update_setup_disabled);
el.totpSetupSubmit.setAttribute('disabled', '');
el.totpQr.innerHTML = '';
$(el.webauthnSetupForm).hide();
}
function show_mfa() {
@@ -191,16 +191,16 @@ and ensure every administrator account for this control panel does the same.</st
function(res) {
el.wrapper.classList.add('loaded');
var has_mfa = false;
res.enabled_mfa.forEach(function(mfa) {
if (mfa.type == "totp") {
render_disable(mfa);
has_mfa = true;
}
});
if (!has_mfa)
render_totp_setup(res.new_mfa.totp);
if (res.new_mfa.totp)
render_totp_setup(res.new_mfa.totp);
if (res.new_mfa.webauthn && 'credentials' in navigator)
render_webauthn_setup(res.new_mfa.webauthn);
}
);
}
@@ -212,7 +212,7 @@ and ensure every administrator account for this control panel does the same.</st
api(
'/mfa/disable',
'POST',
{ type: 'totp' },
{ id: $(this).attr('data-mfa-id') },
function() {
do_logout();
}
@@ -226,7 +226,7 @@ and ensure every administrator account for this control panel does the same.</st
hide_error();
api(
'/mfa/totp/enable',
'/mfa/enable/totp',
'POST',
{
token: $(el.totpSetupToken).val(),
@@ -239,4 +239,22 @@ and ensure every administrator account for this control panel does the same.</st
return false;
}
function do_enable_webauthn() {
navigator.credentials.create({ publicKey: window.mailinabix_mfa_webauthn_provision })
.then(function(creds) {
api(
'/mfa/enable/webauthn',
'POST',
{
attestationObject: arrayBufferToBase64(creds.response['attestationObject']),
clientDataJSON: arrayBufferToBase64(creds.response['clientDataJSON'])
},
function(res) { do_logout(); },
function(res) { render_error(res); }
);
});
return false;
}
</script>

View File

@@ -50,7 +50,7 @@ hide_output $venv/bin/pip install --upgrade pip
hide_output $venv/bin/pip install --upgrade \
rtyaml "email_validator>=1.0.0" "exclusiveprocess" \
flask dnspython python-dateutil expiringdict \
qrcode[pil] pyotp \
qrcode[pil] pyotp pywarp \
"idna>=2.0.0" "cryptography==2.2.2" boto psutil postfix-mta-sts-resolver b2sdk
# CONFIGURATION