1
0
mirror of https://github.com/mail-in-a-box/mailinabox.git synced 2026-03-04 15:54:48 +01:00

Merge remote-tracking branch 'fspoettel/admin-panel-2fa' into totp

# Conflicts:
#	management/auth.py
#	management/daemon.py
#	management/mailconfig.py
#	setup/mail-users.sh
This commit is contained in:
downtownallday
2020-09-28 23:25:16 -04:00
21 changed files with 602 additions and 366 deletions

View File

@@ -1,9 +1,10 @@
import base64, os, os.path, hmac
import base64, os, os.path, hmac, json
from flask import make_response
import utils, totp
from mailconfig import validate_login, get_mail_password, get_mail_user_privileges, get_mfa_state
import utils
from mailconfig import validate_login, get_mail_password, get_mail_user_privileges
from mfa import get_mfa_state, validate_auth_mfa
DEFAULT_KEY_PATH = '/var/lib/mailinabox/api.key'
DEFAULT_AUTH_REALM = 'Mail-in-a-Box Management Server'
@@ -72,40 +73,29 @@ class KeyAuthService:
if username in (None, ""):
raise ValueError("Authorization header invalid.")
elif username == self.key:
# The user passed the API key which grants administrative privs.
# The user passed the master API key which grants administrative privs.
return (None, ["admin"])
else:
# The user is trying to log in with a username and user-specific
# API key or password. Raises or returns privs and an indicator
# whether the user is using their password or a user-specific API-key.
privs, is_user_key = self.get_user_credentials(username, password, env)
# 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))
# If the user is using their API key to login, 2FA has been passed before
if is_user_key:
return (username, privs)
totp_strategy = totp.TOTPStrategy(email=username)
# this will raise `totp.MissingTokenError` or `totp.BadTokenError` for bad requests
totp_strategy.validate_request(request, env)
return (username, privs)
def get_user_credentials(self, email, pw, env):
# Validate a user's credentials. On success returns a list of
# privileges (e.g. [] or ['admin']). On failure raises a ValueError
# with a login error message.
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.
# Sanity check.
if email == "" or pw == "":
raise ValueError("Enter an email address and password.")
is_user_key = False
# 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.
is_user_key = True
pass
else:
# Get the hashed password of the user. Raise a ValueError if the
# email address does not correspond to a user.
@@ -113,6 +103,12 @@ class KeyAuthService:
# Login failed.
raise ValueError("Invalid 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))
# 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.
@@ -120,25 +116,29 @@ class KeyAuthService:
if isinstance(privs, tuple): raise ValueError(privs[0])
# Return a list of privileges.
return (privs, is_user_key)
return privs
def create_user_key(self, email, env):
# Store an HMAC with the client. The hashed message of the HMAC will be the user's
# email address & hashed password and the key will be the master API key. If TOTP
# is active, the key will also include the TOTP secret. The user of course has their
# own email address and password. We assume they do not have the master API key
# (unless they are trusted anyway). The HMAC proves that they authenticated with us
# in some other way to get the HMAC. Including the password means that when
# a user's password is reset, the HMAC changes and they will correctly need to log
# in to the control panel again. This method raises a ValueError if the user does
# not exist, due to get_mail_password.
# 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 master API key as a key,
# which also means that the API key becomes invalid when our master 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" " + ";".join(get_mail_password(email, env)).encode("utf8")
mfa_state = get_mfa_state(email, env)
# 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_mfa_state(email, env), sort_keys=True).encode("utf8")
# Make the HMAC.
hash_key = self.key.encode('ascii')
if mfa_state['type'] == 'totp':
hash_key = hash_key + mfa_state['secret'].encode('ascii')
return hmac.new(hash_key, msg, digestmod="sha256").hexdigest()
def _generate_key(self):

View File

@@ -5,11 +5,12 @@ from functools import wraps
from flask import Flask, request, render_template, abort, Response, send_from_directory, make_response
import auth, utils, totp
import auth, utils, mfa
from mailconfig import get_mail_users, get_mail_users_ex, get_admins, add_mail_user, set_mail_password, set_mail_display_name, 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 mailconfig import get_mfa_state, create_totp_credential, delete_totp_credential
from mfa import get_mfa_state, enable_mfa, disable_mfa
import mfa_totp
env = utils.load_environment()
@@ -36,30 +37,31 @@ app = Flask(__name__, template_folder=os.path.abspath(os.path.join(os.path.dirna
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.
# 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)
except totp.MissingTokenError as e:
error = str(e)
except totp.BadTokenError as e:
# Write a line in the log recording the failed login
log_failed_login(request)
error = str(e)
except ValueError as e:
# Write a line in the log recording the failed login
log_failed_login(request)
# Authentication failed.
error = "Incorrect username or password"
error = str(e)
# 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
# Call view func.
return viewfunc(*args, **kwargs)
elif not error:
if not error:
error = "You are not an administrator."
# Not authorized. Return a 401 (send auth) and a prompt to authorize by default.
@@ -126,28 +128,19 @@ def me():
# Is the caller authorized?
try:
email, privs = auth_service.authenticate(request, env)
except totp.MissingTokenError as e:
return json_response({
"status": "missing_token",
"reason": str(e),
})
except totp.BadTokenError as e:
# Log the failed login
log_failed_login(request)
return json_response({
"status": "bad_token",
"reason": str(e),
})
except ValueError as e:
# Log the failed login
log_failed_login(request)
return json_response({
"status": "invalid",
"reason": "Incorrect username or password",
})
if "missing-totp-token" in str(e):
return json_response({
"status": "missing-totp-token",
"reason": str(e),
})
else:
# Log the failed login
log_failed_login(request)
return json_response({
"status": "invalid",
"reason": str(e),
})
resp = {
"status": "ok",
@@ -418,47 +411,34 @@ def ssl_provision_certs():
@app.route('/mfa/status', methods=['GET'])
@authorized_personnel_only
def two_factor_auth_get_status():
email, _ = auth_service.authenticate(request, env)
mfa_state = get_mfa_state(email, env)
if mfa_state['type'] == 'totp':
return json_response({ "type": 'totp' })
secret = totp.get_secret()
secret_url = totp.get_otp_uri(secret, email)
secret_qr = totp.get_qr_code(secret_url)
def mfa_get_status():
return json_response({
"type": None,
"totp_secret": secret,
"totp_qr": secret_qr
"enabled_mfa": get_mfa_state(request.user_email, env),
"new_mfa": {
"totp": mfa_totp.provision(request.user_email, env)
}
})
@app.route('/mfa/totp/enable', methods=['POST'])
@authorized_personnel_only
def totp_post_enable():
email, _ = auth_service.authenticate(request, env)
secret = request.form.get('secret')
token = request.form.get('token')
if type(secret) != str or type(token) != str or len(token) != 6 or len(secret) != 32:
label = request.form.get('label')
if type(token) != str:
return json_response({ "error": 'bad_input' }, 400)
try:
mfa_totp.validate_secret(secret)
enable_mfa(request.user_email, "totp", secret, token, label, env)
except ValueError as e:
return str(e)
return "OK"
if totp.validate(secret, token):
create_totp_credential(email, secret, env)
return json_response({})
return json_response({ "error": 'token_mismatch' }, 400)
@app.route('/mfa/totp/disable', methods=['POST'])
@app.route('/mfa/disable', methods=['POST'])
@authorized_personnel_only
def totp_post_disable():
email, _ = auth_service.authenticate(request, env)
delete_totp_credential(email, env)
return json_response({})
disable_mfa(request.user_email, request.form.get('mfa-id'), env)
return "OK"
# WEB

View File

@@ -1129,75 +1129,6 @@ def get_required_aliases(env):
return aliases
# multi-factor auth
def get_mfa_state(email, env):
# find the user
conn = open_database(env)
user = find_mail_user(env, email, ['objectClass','totpSecret','totpMruToken'], conn)
if user is None or 'totpUser' not in user['objectClass']:
return { 'type': None }
secret = user['totpSecret'][0]
mru_token = None
if len(user['totpMruToken'])>0:
mru_token = user['totpMruToken'][0]
return {
'type': 'totp',
'secret': secret,
'mru_token': '' if mru_token is None else mru_token
}
def create_totp_credential(email, secret, env):
validate_totp_secret(secret)
conn = open_database(env)
user = find_mail_user(env, email, ['objectClass','totpSecret'], conn)
if user is None:
return ("That's not a user (%s)." % email, 400)
attrs = {
"totpSecret": secret,
}
if 'totpUser' not in user['objectClass']:
attrs['objectClass'] = user['objectClass'].copy()
attrs['objectClass'].append('totpUser')
conn.add_or_modify(user['dn'], user, attrs.keys(), None, attrs)
return "OK"
def set_mru_totp_code(email, token, env):
conn = open_database(env)
user = find_mail_user(env, email, ['objectClass','totpMruToken'], conn)
if user is None:
return ("That's not a user (%s)." % email, 400)
if 'totpUser' not in user['objectClass']:
return ("User (%s) not configured for TOTP" % email, 400)
attrs = {
"totpMruToken": token
}
conn.add_or_modify(user['dn'], user, attrs.keys(), None, attrs)
return "OK"
def delete_totp_credential(email, env):
conn = open_database(env)
user = find_mail_user(env, email, ['objectClass','totpSecret','totpMruToken'], conn)
if user is None:
return ("That's not a user (%s)." % email, 400)
if 'totpUser' not in user['objectClass']:
return "OK"
attrs = {
"totpMruToken": None,
"totpSecret": None,
"objectClass": user["objectClass"].copy()
}
attrs["objectClass"].remove("totpUser")
conn.add_or_modify(user['dn'], user, attrs.keys(), None, attrs)
return "OK"
def kick(env, mail_result=None):
results = []
@@ -1259,12 +1190,6 @@ def validate_password(pw):
if len(pw) < 8:
raise ValueError("Passwords must be at least eight characters.")
def validate_totp_secret(secret):
if type(secret) != str or secret.strip() == "":
raise ValueError("No secret provided.")
if len(secret) != 32:
raise ValueError("Secret should be a 32 characters base32 string")
if __name__ == "__main__":
import sys
if len(sys.argv) > 2 and sys.argv[1] == "validate-email":

113
management/mfa.py Normal file
View File

@@ -0,0 +1,113 @@
# -*- indent-tabs-mode: t; tab-width: 4; python-indent-offset: 4; -*-
from mailconfig import open_database, find_mail_user
import mfa_totp
def strip_order_prefix(rec, attributes):
'''strip the order prefix from X-ORDERED ldap values for the
list of attributes specified
`rec` is modified in-place
the server returns X-ORDERED values in-order so the values will be
correctly orded in the record.
For example, the function will change:
totpSecret: {0}secret1
totpSecret: {1}secret2
to:
totpSecret: secret1
totpSecret: secret2
TODO: move to backend.py and/or integrate with LdapConnection.search()
'''
for attr in attributes:
# ignore attribute that doesn't exist
if not attr in rec: continue
# ..as well as None values and empty list
if not rec[attr]: continue
newvals = []
for val in rec[attr]:
i = val.find('}')
if i>=0: newvals.append(val[i+1:])
rec[attr] = newvals
def get_mfa_user(email, env, conn=None):
'''get the ldap record for the user
'''
user = find_mail_user(env, email, ['objectClass','totpSecret','totpMruToken','totpLabel'], conn)
if not user:
raise ValueError("User does not exist.")
strip_order_prefix(user, ['totpSecret','totpMruToken','totpLabel'])
return user
def get_mfa_state(email, env):
'''return details about what MFA schemes are enabled for a user
ordered by the priority that the scheme will be tried, with index
zero being the first.
'''
user = get_mfa_user(email, env)
state_list = []
state_list += mfa_totp.get_state(user)
return state_list
def enable_mfa(email, type, secret, token, label, env):
'''enable MFA using the scheme specified in `type`. users may have
multiple mfa schemes enabled of the same type.
'''
user = get_mfa_user(email, env)
if type == "totp":
mfa_totp.enable(user, secret, token, label, env)
else:
raise ValueError("Invalid MFA type.")
def disable_mfa(email, mfa_id, env):
'''disable a specific MFA scheme. `mfa_id` identifies the specific
entry and is available in the `id` field of an item in the list
obtained from get_mfa_state()
'''
user = get_mfa_user(email, env)
if mfa_id is None:
# Disable all MFA for a user.
mfa_totp.disable(user, None, env)
elif mfa_id.startswith("totp:"):
# Disable a particular MFA mode for a user.
mfa_totp.disable(user, mfa_id, env)
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
# a tuple (status, [hints]). status is True for a successful
# MFA login, False for a missing token. If status is False,
# hints is an array of codes that indicate what the user
# can try. Possible codes are:
# "missing-totp-token"
# "invalid-totp-token"
mfa_state = get_mfa_state(email, env)
# If no MFA modes are added, return True.
if len(mfa_state) == 0:
return (True, [])
# Try the enabled MFA modes.
hints = set()
for mfa_mode in mfa_state:
if mfa_mode["type"] == "totp":
user = get_mfa_user(email, env)
result, hint = mfa_totp.validate(user, mfa_mode, request, True, env)
if not result:
hints.add(hint)
else:
return (True, [])
# On a failed login, indicate failure and any hints for what the user can do instead.
return (False, list(hints))

165
management/mfa_totp.py Normal file
View File

@@ -0,0 +1,165 @@
# -*- indent-tabs-mode: t; tab-width: 4; python-indent-offset: 4; -*-
import hashlib
import base64
import hmac
import pyotp
import qrcode
import io
import os
from mailconfig import open_database
def totp_id_from_index(user, index):
'''return the sha-256 hash of the corresponding totpSecret as the
unique id for the totp entry. use the hash and not the index
itself to ensure a change in the totp order does not cause an
unexpected change
'''
m = hashlib.sha256()
m.update(user['totpSecret'][index].encode("utf8"))
return 'totp:' + m.hexdigest()
def totp_index_from_id(user, id):
'''return the index of the corresponding id from the list of totp
entries for a user, or -1 if not found
'''
for index in range(0, len(user['totpSecret'])):
xid = totp_id_from_index(user, index)
if xid == id:
return index
return -1
def get_state(user):
state_list = []
# totp
for idx in range(0, len(user['totpSecret'])):
state_list.append({
'id': totp_id_from_index(user, idx),
'type': 'totp',
'secret': user['totpSecret'][idx],
'mru_token': user['totpMruToken'][idx],
'label': user['totpLabel'][idx]
})
return state_list
def enable(user, secret, token, label, env):
validate_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.")
mods = {
"totpSecret": user['totpSecret'].copy() + [secret],
"totpMruToken": user['totpMruToken'].copy() + [''],
"totpLabel": user['totpLabel'].copy() + [label or '']
}
if 'totpUser' not in user['objectClass']:
mods['objectClass'] = user['objectClass'].copy() + ['totpUser']
conn = open_database(env)
conn.modify_record(user, mods)
def set_mru_token(user, id, token, env):
# return quietly if the user is not configured for TOTP
if 'totpUser' not in user['objectClass']: return
# ensure the id is valid
idx = totp_index_from_id(user, id)
if idx<0:
raise ValueError('MFA/totp mru index is out of range')
# store the token
mods = { "totpMruToken": user['totpMruToken'].copy() }
mods['totpMruToken'][idx] = token
conn = open_database(env)
conn.modify_record(user, mods)
def disable(user, id, env):
# Disable a particular MFA mode for a user.
if id is None:
# Disable all totp
mods = {
"objectClass": user["objectClass"].copy(),
"totpMruToken": None,
"totpSecret": None,
"totpLabel": None
}
mods["objectClass"].remove("totpUser")
open_database(env).modify_record(user, mods)
else:
# Disable totp at index specified
idx = totp_index_from_id(user, id)
if idx<0 or idx>=len(user['totpSecret']):
raise ValueError('MFA/totp mru index is out of range')
mods = {
"objectClass": user["objectClass"].copy(),
"totpMruToken": user["totpMruToken"].copy(),
"totpSecret": user["totpSecret"].copy(),
"totpLabel": user["totpLabel"].copy()
}
mods["totpMruToken"].pop(idx)
mods["totpSecret"].pop(idx)
mods["totpLabel"].pop(idx)
if len(mods["totpSecret"])==0:
mods['objectClass'].remove('totpUser')
open_database(env).modify_record(user, mods)
def validate_secret(secret):
if type(secret) != str or secret.strip() == "":
raise ValueError("No secret provided.")
if len(secret) != 32:
raise ValueError("Secret should be a 32 characters base32 string")
def provision(email, env):
# Make a new secret.
secret = base64.b32encode(os.urandom(20)).decode('utf-8')
validate_secret(secret) # sanity check
# 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"
)
# Generate a QR code as a base64-encode PNG image.
qr = qrcode.make(uri)
byte_arr = io.BytesIO()
qr.save(byte_arr, format='PNG')
png_b64 = base64.b64encode(byte_arr.getvalue()).decode('utf-8')
return {
"type": "totp",
"secret": secret,
"qr_code_base64": png_b64
}
def validate(user, state, request, save_mru, env):
# Check that a token is present in the X-Auth-Token header.
# If not, give a hint that one can be supplied.
token = request.headers.get('x-auth-token')
if not token:
return (False, "missing-totp-token")
# Check for a replay attack.
if hmac.compare_digest(token, state['mru_token'] or ""):
# If the token fails, skip this MFA mode.
return (False, "invalid-totp-token")
# Check the token.
totp = pyotp.TOTP(state["secret"])
if not totp.verify(token, valid_window=1):
return (False, "invalid-totp-token")
# On success, record the token to prevent a replay attack.
if save_mru:
set_mru_token(user, state['id'], token, env)
return (True, None)

View File

@@ -93,16 +93,18 @@
<li class="dropdown-header">Advanced Pages</li>
<li><a href="#custom_dns" onclick="return show_panel(this);">Custom DNS</a></li>
<li><a href="#external_dns" onclick="return show_panel(this);">External DNS</a></li>
<li><a href="#two_factor_auth" onclick="return show_panel(this);">Two-Factor Authentication</a></li>
<li><a href="/admin/munin" target="_blank">Munin Monitoring</a></li>
</ul>
</li>
<li class="dropdown">
<a href="#" class="dropdown-toggle" data-toggle="dropdown">Mail <b class="caret"></b></a>
<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>
<li><a href="#users" onclick="return show_panel(this);">Users</a></li>
<li><a href="#aliases" onclick="return show_panel(this);">Aliases</a></li>
<li class="divider"></li>
<li class="dropdown-header">Your Account</li>
<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>
@@ -132,8 +134,8 @@
{% include "custom-dns.html" %}
</div>
<div id="panel_two_factor_auth" class="admin_panel">
{% include "two-factor-auth.html" %}
<div id="panel_mfa" class="admin_panel">
{% include "mfa.html" %}
</div>
<div id="panel_login" class="admin_panel">

View File

@@ -61,6 +61,13 @@ sudo tools/mail.py user make-admin me@{{hostname}}</pre>
<input name="password" type="password" class="form-control" id="loginPassword" placeholder="Password">
</div>
</div>
<div class="form-group" id="loginOtp">
<label for="loginOtpInput" class="col-sm-3 control-label">Code</label>
<div class="col-sm-9">
<input type="text" class="form-control" id="loginOtpInput" placeholder="6-digit code">
<div class="help-block" style="margin-top: 5px; font-size: 90%">Enter the six-digit code generated by your two factor authentication app.</div>
</div>
</div>
<div class="form-group">
<div class="col-sm-offset-3 col-sm-9">
<div class="checkbox">
@@ -70,12 +77,6 @@ sudo tools/mail.py user make-admin me@{{hostname}}</pre>
</div>
</div>
</div>
<div class="form-group" id="loginOtp">
<div class="col-sm-offset-3 col-sm-9">
<label for="loginOtpInput" class="control-label">Two-Factor Code</label>
<input type="text" class="form-control" id="loginOtpInput" placeholder="6-digit code">
</div>
</div>
<div class="form-group">
<div class="col-sm-offset-3 col-sm-9">
<button type="submit" class="btn btn-default">Sign in</button>
@@ -111,13 +112,18 @@ function do_login() {
// This API call always succeeds. It returns a JSON object indicating
// whether the request was authenticated or not.
if (response.status != 'ok') {
if (response.status === 'missing_token' && !$('#loginForm').hasClass('is-twofactor')) {
if (response.status === 'missing-totp-token' || (response.status === 'invalid' && response.reason == 'invalid-totp-token')) {
$('#loginForm').addClass('is-twofactor');
setTimeout(() => {
$('#loginOtpInput').focus();
});
if (response.reason === "invalid-totp-token") {
show_modal_error("Login Failed", "Incorrect two factor authentication token.");
} else {
setTimeout(() => {
$('#loginOtpInput').focus();
});
}
} else {
$('#loginForm').removeClass('is-twofactor');
// Show why the login failed.
show_modal_error("Login Failed", response.reason)

View File

@@ -33,38 +33,65 @@
<h2>Two-Factor Authentication</h2>
<p>When two-factor authentication is enabled, you will be prompted to enter a six digit code from an
authenticator app (usually on your phone) when you log into this control panel.</p>
<div class="panel panel-danger">
<div class="panel-heading">
Enabling two-factor authentication does not protect access to your email
</div>
<div class="panel-body">
Enabling two-factor authentication on this page only limits access to this control panel. Remember that most websites allow you to
reset your password by checking your email, so anyone with access to your email can typically take over
your other accounts. Additionally, if your email address or any alias that forwards to your email
address is a typical domain control validation address (e.g admin@, administrator@, postmaster@, hostmaster@,
webmaster@, abuse@), extra care should be taken to protect the account. <strong>Always use a strong password,
and ensure every administrator account for this control panel does the same.</strong>
</div>
</div>
<div class="twofactor">
<div class="loading-indicator">Loading...</div>
<form id="totp-setup">
<p>After enabling two-factor authentication, any login to the admin panel will require you to enter a time-limited 6-digit number from an authenticator app after entering your normal credentials.</p>
<h3>Setup Instructions</h3>
<div class="form-group">
<h3>Setup Instructions</h3>
<p>1. Scan the QR code or enter the secret into an authenticator app (e.g. Google Authenticator)</p>
<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
other two-factor authentication app</a> that supports TOTP.</p>
</div>
<div class="form-group">
<p style="margin-bottom: 0">2. Scan the QR code in the app or directly enter the secret into the app:</p>
<div id="totp-setup-qr"></div>
</div>
<div class="form-group">
<label for="otp">2. Enter the code displayed in the Authenticator app</label>
<p>You will have to log into the admin panel again after enabling two-factor authentication.</p>
<label for="otp-label" style="font-weight: normal">3. Optionally, give your device a label so that you can remember what device you set it up on:</label>
<input type="text" id="totp-setup-label" class="form-control" placeholder="my phone" />
</div>
<div class="form-group">
<label for="otp" style="font-weight: normal">4. Use the app to generate your first six-digit code and enter it here:</label>
<input type="text" id="totp-setup-token" class="form-control" placeholder="6-digit code" />
</div>
<input type="hidden" id="totp-setup-secret" />
<div class="form-group">
<button id="totp-setup-submit" disabled type="submit" class="btn">Enable two-factor authentication</button>
<p>When you click Enable Two-Factor Authentication, you will be logged out of the control panel and will have to log in
again, now using your two-factor authentication app.</p>
<button id="totp-setup-submit" disabled type="submit" class="btn">Enable Two-Factor Authentication</button>
</div>
</form>
<form id="disable-2fa">
<div class="form-group">
<p>Two-factor authentication is active for your account. You can disable it by clicking below button.</p>
<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>
<button type="submit" class="btn btn-danger">Disable Two-Factor Authentication</button>
</div>
</form>
@@ -80,6 +107,7 @@
totpSetupForm: document.getElementById('totp-setup'),
totpSetupToken: document.getElementById('totp-setup-token'),
totpSetupSecret: document.getElementById('totp-setup-secret'),
totpSetupLabel: document.getElementById('totp-setup-label'),
totpQr: document.getElementById('totp-setup-qr'),
totpSetupSubmit: document.querySelector('#totp-setup-submit'),
wrapper: document.querySelector('.twofactor')
@@ -101,30 +129,29 @@
}
}
function render_totp_setup(res) {
function render_qr_code(encoded) {
var img = document.createElement('img');
img.src = encoded;
function render_totp_setup(provisioned_totp) {
var img = document.createElement('img');
img.src = "data:image/png;base64," + provisioned_totp.qr_code_base64;
var code = document.createElement('div');
code.innerHTML = `Secret: ${res.totp_secret}`;
var code = document.createElement('div');
code.innerHTML = `Secret: ${provisioned_totp.secret}`;
el.totpQr.appendChild(img);
el.totpQr.appendChild(code);
}
el.totpQr.appendChild(img);
el.totpQr.appendChild(code);
el.totpSetupToken.addEventListener('input', update_setup_disabled);
el.totpSetupForm.addEventListener('submit', do_enable_totp);
el.totpSetupSecret.setAttribute('value', res.totp_secret);
render_qr_code(res.totp_qr);
el.totpSetupSecret.setAttribute('value', provisioned_totp.secret);
el.wrapper.classList.add('disabled');
}
function render_disable() {
function render_disable(mfa) {
el.disableForm.addEventListener('submit', do_disable);
el.wrapper.classList.add('enabled');
if (mfa.label)
$("#mfa-device-label").text(" on device '" + mfa.label + "'");
}
function hide_error() {
@@ -154,7 +181,7 @@
el.totpQr.innerHTML = '';
}
function show_two_factor_auth() {
function show_mfa() {
reset_view();
api(
@@ -163,8 +190,17 @@
{},
function(res) {
el.wrapper.classList.add('loaded');
var isTotpEnabled = res.type === 'totp'
return isTotpEnabled ? render_disable(res) : render_totp_setup(res);
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);
}
);
}
@@ -174,9 +210,9 @@
hide_error();
api(
'/mfa/totp/disable',
'/mfa/disable',
'POST',
{},
{ type: 'totp' },
function() {
do_logout();
}
@@ -194,7 +230,8 @@
'POST',
{
token: $(el.totpSetupToken).val(),
secret: $(el.totpSetupSecret).val()
secret: $(el.totpSetupSecret).val(),
label: $(el.totpSetupLabel).val()
},
function(res) {
do_logout();

View File

@@ -1,72 +0,0 @@
import base64
import hmac
import io
import os
import struct
import time
import pyotp
import qrcode
from mailconfig import get_mfa_state, set_mru_totp_code
def get_secret():
return base64.b32encode(os.urandom(20)).decode('utf-8')
def get_otp_uri(secret, email):
return pyotp.TOTP(secret).provisioning_uri(
name=email,
issuer_name='mailinabox'
)
def get_qr_code(data):
qr = qrcode.make(data)
byte_arr = io.BytesIO()
qr.save(byte_arr, format='PNG')
encoded = base64.b64encode(byte_arr.getvalue()).decode('utf-8')
return 'data:image/png;base64,{}'.format(encoded)
def validate(secret, token):
"""
@see https://tools.ietf.org/html/rfc6238#section-4
@see https://tools.ietf.org/html/rfc4226#section-5.4
"""
totp = pyotp.TOTP(secret)
return totp.verify(token, valid_window=1)
class MissingTokenError(ValueError):
pass
class BadTokenError(ValueError):
pass
class TOTPStrategy():
def __init__(self, email):
self.type = 'totp'
self.email = email
def store_successful_login(self, token, env):
return set_mru_totp_code(self.email, token, env)
def validate_request(self, request, env):
mfa_state = get_mfa_state(self.email, env)
# 2FA is not enabled, we can skip further checks
if mfa_state['type'] != 'totp':
return True
# If 2FA is enabled, raise if:
# 1. no token is provided via `x-auth-token`
# 2. a previously supplied token is used (to counter replay attacks)
# 3. the token is invalid
# in that case, we need to raise and indicate to the client to supply a TOTP
token_header = request.headers.get('x-auth-token')
if not token_header:
raise MissingTokenError("Two factor code missing (no x-auth-token supplied)")
# TODO: Should a token replay be handled as its own error?
if hmac.compare_digest(token_header, mfa_state['mru_token']) or validate(mfa_state['secret'], token_header) != True:
raise BadTokenError("Two factor code incorrect")
self.store_successful_login(token_header, env)
return True