mirror of
https://github.com/mail-in-a-box/mailinabox.git
synced 2025-04-04 00:17:06 +00: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:
commit
00fc94d3c1
@ -1,5 +1,5 @@
|
||||
#
|
||||
# MiaB-LDAP's directory schema for Time-based one time passwords (TOTP)
|
||||
# MiaB-LDAP's directory schema for time-based one time passwords (TOTP)
|
||||
#
|
||||
# MiaB LDAP UUID(v4): 7392cdda-5ec8-431f-9936-0000273c0167
|
||||
# or: 1939000794.24264.17183.39222.658243943
|
||||
@ -8,28 +8,48 @@
|
||||
objectIdentifier MiabLDAProot 2.25.1939000794.24264.17183.39222.658243943
|
||||
|
||||
objectIdentifier MiabLDAPmfa MiabLDAProot:1
|
||||
objectIdentifier MiabLDAPmfaAttributeType MiabLDAPmfa:3
|
||||
objectIdentifier MiabLDAPmfaObjectClass MiabLDAPmfa:4
|
||||
objectIdentifier MiabLDAPmfaAttributeType MiabLDAPmfa:2
|
||||
objectIdentifier MiabLDAPmfaObjectClass MiabLDAPmfa:3
|
||||
|
||||
# secret consists of base32 characters (see RFC 4648)
|
||||
|
||||
attributetype ( MiabLDAPmfaAttributeType:1
|
||||
DESC 'TOTP secret'
|
||||
NAME 'totpSecret'
|
||||
SYNTAX 1.3.6.1.4.1.1466.115.121.1.26
|
||||
X-ORDERED 'VALUES'
|
||||
EQUALITY caseExactIA5Match )
|
||||
|
||||
|
||||
# tokens are a base-10 string of N digits, but set the syntax to
|
||||
# IA5String anyway
|
||||
|
||||
attributetype ( MiabLDAPmfaAttributeType:2
|
||||
DESC 'TOTP last token used'
|
||||
NAME 'totpMruToken'
|
||||
SYNTAX 1.3.6.1.4.1.1466.115.121.1.26
|
||||
X-ORDERED 'VALUES'
|
||||
EQUALITY caseExactIA5Match )
|
||||
|
||||
objectClass ( MiabLDAPmfaObjectClass:3
|
||||
|
||||
# The label is currently any text supplied by the user, which is used
|
||||
# as a reminder of where the secret is stored when logging in (where
|
||||
# the authenticator app is, that holds the secret). eg "my samsung
|
||||
# phone"
|
||||
|
||||
attributetype ( MiabLDAPmfaAttributeType:3
|
||||
DESC 'TOTP device label'
|
||||
NAME 'totpLabel'
|
||||
SYNTAX 1.3.6.1.4.1.1466.115.121.1.26
|
||||
X-ORDERED 'VALUES'
|
||||
EQUALITY caseIgnoreIA5Match )
|
||||
|
||||
|
||||
# The TOTP objectClass
|
||||
|
||||
objectClass ( MiabLDAPmfaObjectClass:1
|
||||
NAME 'totpUser'
|
||||
DESC 'MiaB-LDAP User TOTP settings'
|
||||
DESC 'MiaB-LDAP TOTP settings for a user'
|
||||
SUP top
|
||||
AUXILIARY
|
||||
MUST ( totpSecret )
|
||||
MAY ( totpMruToken ) )
|
||||
MUST ( totpSecret $ totpMruToken $ totpLabel ) )
|
||||
|
@ -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):
|
||||
|
@ -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,27 +128,18 @@ 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:
|
||||
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": "Incorrect username or password",
|
||||
"reason": str(e),
|
||||
})
|
||||
|
||||
resp = {
|
||||
@ -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
|
||||
|
||||
|
@ -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
113
management/mfa.py
Normal 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
165
management/mfa_totp.py
Normal 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)
|
@ -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 & 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">
|
||||
|
@ -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');
|
||||
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)
|
||||
|
||||
|
@ -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) {
|
||||
function render_totp_setup(provisioned_totp) {
|
||||
var img = document.createElement('img');
|
||||
img.src = encoded;
|
||||
img.src = "data:image/png;base64," + provisioned_totp.qr_code_base64;
|
||||
|
||||
var code = document.createElement('div');
|
||||
code.innerHTML = `Secret: ${res.totp_secret}`;
|
||||
code.innerHTML = `Secret: ${provisioned_totp.secret}`;
|
||||
|
||||
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();
|
@ -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
|
@ -575,7 +575,7 @@ apply_access_control() {
|
||||
# service accounts (except management):
|
||||
# can bind but not change passwords, including their own
|
||||
# can read all attributes of all users but not userPassword,
|
||||
# totpSecret, or totpMruToken
|
||||
# totpSecret, totpMruToken, or totpLabel
|
||||
# can read config subtree (permitted-senders, domains)
|
||||
# no access to services subtree, except their own dn
|
||||
# management service account:
|
||||
@ -584,8 +584,8 @@ apply_access_control() {
|
||||
# users:
|
||||
# can bind and change their own password
|
||||
# can read and change their own shadowLastChange
|
||||
# cannot read or modify totpSecret, totpMruToken
|
||||
# can read attributess of other users except mailaccess, totpSecret, totpMruToken
|
||||
# cannot read or modify totpSecret, totpMruToken, totpLabel
|
||||
# can read attributess of other users except mailaccess, totpSecret, totpMruToken, totpLabel
|
||||
# no access to config subtree
|
||||
# no access to services subtree
|
||||
#
|
||||
@ -607,7 +607,7 @@ olcAccess: to attrs=userPassword
|
||||
by self =wx
|
||||
by anonymous auth
|
||||
by * none
|
||||
olcAccess: to attrs=totpSecret,totpMruToken
|
||||
olcAccess: to attrs=totpSecret,totpMruToken,totpLabel
|
||||
by dn.exact="cn=management,${LDAP_SERVICES_BASE}" write
|
||||
by dn.exact="gidNumber=0+uidNumber=0,cn=peercred,cn=external,cn=auth" read
|
||||
by * none
|
||||
|
@ -17,7 +17,6 @@ source setup/functions.sh # load our functions
|
||||
source /etc/mailinabox.conf # load global vars
|
||||
source ${STORAGE_ROOT}/ldap/miab_ldap.conf # user-data specific vars
|
||||
|
||||
|
||||
# ### User Authentication
|
||||
|
||||
# Have Dovecot query our database, and not system users, for authentication.
|
||||
|
@ -183,9 +183,11 @@ def migration_12(env):
|
||||
conn.close()
|
||||
|
||||
def migration_13(env):
|
||||
# Add a table for `totp_credentials`
|
||||
# Add the "mfa" table for configuring MFA for login to the control panel.
|
||||
db = os.path.join(env["STORAGE_ROOT"], 'mail/users.sqlite')
|
||||
shell("check_call", ["sqlite3", db, "CREATE TABLE IF NOT EXISTS totp_credentials (id INTEGER PRIMARY KEY AUTOINCREMENT, user_email TEXT NOT NULL UNIQUE, secret TEXT NOT NULL, mru_token TEXT, FOREIGN KEY (user_email) REFERENCES users(email) ON DELETE CASCADE);"])
|
||||
shell("check_call", ["sqlite3", db, "CREATE TABLE mfa (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL UNIQUE, type TEXT NOT NULL, secret TEXT NOT NULL, mru_token TEXT, label TEXT, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE);"])
|
||||
|
||||
###########################################################
|
||||
|
||||
|
||||
def migration_miabldap_1(env):
|
||||
@ -352,7 +354,8 @@ def run_miabldap_migrations():
|
||||
print(e)
|
||||
print()
|
||||
print("Your system may be in an inconsistent state now. We're terribly sorry. A re-install from a backup might be the best way to continue.")
|
||||
sys.exit(1)
|
||||
#sys.exit(1)
|
||||
raise e
|
||||
|
||||
ourver = next_ver
|
||||
|
||||
|
@ -8,7 +8,7 @@
|
||||
import uuid, os, sqlite3, ldap3, hashlib
|
||||
|
||||
|
||||
def add_user(env, ldapconn, search_base, users_base, domains_base, email, password, privs, totp_secret, totp_mru_token, cn=None):
|
||||
def add_user(env, ldapconn, search_base, users_base, domains_base, email, password, privs, totp, cn=None):
|
||||
# Add a sqlite user to ldap
|
||||
# env are the environment variables
|
||||
# ldapconn is the bound ldap connection
|
||||
@ -18,8 +18,7 @@ def add_user(env, ldapconn, search_base, users_base, domains_base, email, passwo
|
||||
# email is the user's email
|
||||
# password is the user's current sqlite password hash
|
||||
# privs is an array of privilege names for the user
|
||||
# totp_secret is the TOTP secret or None
|
||||
# totp_mru_token is the TOP most recently used token or None
|
||||
# totp contains the list of secrets, mru tokens, and labels
|
||||
# cn is the user's common name [optional]
|
||||
#
|
||||
# the email address should be as-is from sqlite (encoded as
|
||||
@ -77,11 +76,11 @@ def add_user(env, ldapconn, search_base, users_base, domains_base, email, passwo
|
||||
attrs["sn"] = cn[cn.find(' ')+1:]
|
||||
|
||||
# add TOTP, if enabled
|
||||
if totp_secret:
|
||||
if totp:
|
||||
objectClasses.append('totpUser')
|
||||
attrs['totpSecret'] = totp_secret
|
||||
if totp_mru_token:
|
||||
attrs['totpMruToken'] = totp_mru_token
|
||||
attrs['totpSecret'] = totp["secret"]
|
||||
attrs['totpMruToken'] = totp["mru_token"]
|
||||
attrs['totpLabel'] = totp["label"]
|
||||
|
||||
# Add user
|
||||
dn = "uid=%s,%s" % (uid, users_base)
|
||||
@ -105,23 +104,33 @@ def create_users(env, conn, ldapconn, ldap_base, ldap_users_base, ldap_domains_b
|
||||
# iterate through sqlite 'users' table and create each user in
|
||||
# ldap. returns a map of email->dn
|
||||
|
||||
try:
|
||||
# select users
|
||||
c = conn.cursor()
|
||||
c.execute("select users.email, users.password, users.privileges, totp_credentials.secret, totp_credentials.mru_token from users left join totp_credentials on users.email = totp_credentials.user_email")
|
||||
|
||||
except:
|
||||
# old version of miab
|
||||
c = conn.cursor()
|
||||
c.execute("SELECT email, password, privileges, NULL as secret, NULL as mru_token from users")
|
||||
c.execute("SELECT id, email, password, privileges from users")
|
||||
|
||||
users = {}
|
||||
for row in c:
|
||||
email=row[0]
|
||||
password=row[1]
|
||||
privs=row[2]
|
||||
totp_secret=row[3]
|
||||
totp_mru_token=row[4]
|
||||
dn = add_user(env, ldapconn, ldap_base, ldap_users_base, ldap_domains_base, email, password, privs.split("\n"), totp_secret, totp_mru_token)
|
||||
user_id=row[0]
|
||||
email=row[1]
|
||||
password=row[2]
|
||||
privs=row[3]
|
||||
totp = None
|
||||
|
||||
c2 = conn.cursor()
|
||||
c2.execute("SELECT secret, mru_token, label from mfa where user_id=? and type='totp'", (user_id,));
|
||||
rowidx = 0
|
||||
for row2 in c2:
|
||||
if totp is None:
|
||||
totp = {
|
||||
"secret": [],
|
||||
"mru_token": [],
|
||||
"label": []
|
||||
}
|
||||
totp["secret"].append("{%s}%s" % (rowidx, row2[0]))
|
||||
totp["mru_token"].append("{%s}%s" % (rowidx, row2[1] or ''))
|
||||
totp["label"].append("{%s}%s" % (rowidx, row2[2] or ''))
|
||||
|
||||
dn = add_user(env, ldapconn, ldap_base, ldap_users_base, ldap_domains_base, email, password, privs.split("\n"), totp)
|
||||
users[email] = dn
|
||||
return users
|
||||
|
||||
|
@ -29,7 +29,7 @@ create_user() {
|
||||
local email="$1"
|
||||
local pass="${2:-$email}"
|
||||
local priv="${3:-test}"
|
||||
local totpVal="${4:-}" # "secret,token"
|
||||
local totpVal="${4:-}" # "secret,token,label"
|
||||
local localpart="$(awk -F@ '{print $1}' <<< "$email")"
|
||||
local domainpart="$(awk -F@ '{print $2}' <<< "$email")"
|
||||
#local uid="$localpart"
|
||||
@ -47,12 +47,13 @@ create_user() {
|
||||
local totpObjectClass=""
|
||||
local totpSecret="$(awk -F, '{print $1}' <<< "$totpVal")"
|
||||
local totpMruToken="$(awk -F, '{print $2}' <<< "$totpVal")"
|
||||
local totpLabel="$(awk -F, '{print $3}' <<< "$totpVal")"
|
||||
if [ ! -z "$totpVal" ]; then
|
||||
local nl=$'\n'
|
||||
totpObjectClass="${nl}objectClass: totpUser"
|
||||
totpSecret="${nl}totpSecret: ${totpSecret}"
|
||||
[ ! -z "$totpMruToken" ] && \
|
||||
totpMruToken="${nl}totpMruToken: ${totpMruToken}"
|
||||
totpSecret="${nl}totpSecret: {0}${totpSecret}"
|
||||
totpMruToken="${nl}totpMruToken: {0}${totpMruToken}"
|
||||
totpLabel="${nl}totpLabel: {0}${totpLabel}"
|
||||
fi
|
||||
|
||||
ldapadd -H "$LDAP_URL" -x -D "$LDAP_ADMIN_DN" -w "$LDAP_ADMIN_PASSWORD" >>$TEST_OF 2>&1 <<EOF
|
||||
@ -66,7 +67,7 @@ sn: $localpart
|
||||
displayName: $localpart
|
||||
mail: $email
|
||||
maildrop: $email
|
||||
mailaccess: $priv${totpSecret}${totpMruToken}
|
||||
mailaccess: $priv${totpSecret}${totpMruToken}${totpLabel}
|
||||
userPassword: $(slappasswd_hash "$pass")
|
||||
EOF
|
||||
[ $? -ne 0 ] && die "Unable to add user $dn (as admin)"
|
||||
|
@ -218,6 +218,18 @@ mgmt_get_totp_token() {
|
||||
return 1
|
||||
}
|
||||
|
||||
mgmt_mfa_status() {
|
||||
local user="$1"
|
||||
local pw="$2"
|
||||
record "[Get MFA status]"
|
||||
if ! mgmt_rest_as_user "GET" "/admin/mfa/status" "$user" "$pw"; then
|
||||
REST_ERROR="Failed: GET /admin/mfa/status: $REST_ERROR"
|
||||
return 1
|
||||
fi
|
||||
# json is in REST_OUTPUT...
|
||||
return 0
|
||||
}
|
||||
|
||||
|
||||
mgmt_totp_enable() {
|
||||
# enable TOTP for user specified
|
||||
@ -228,17 +240,17 @@ mgmt_totp_enable() {
|
||||
|
||||
local user="$1"
|
||||
local pw="$2"
|
||||
local label="$3" # optional
|
||||
TOTP_SECRET=""
|
||||
|
||||
record "[Enable TOTP for $user]"
|
||||
|
||||
# 1. get a totp secret
|
||||
if ! mgmt_rest_as_user "GET" "/admin/mfa/status" "$user" "$pw"; then
|
||||
REST_ERROR="Failed: GET/admin/mfa/status: $REST_ERROR"
|
||||
if ! mgmt_mfa_status "$user" "$pw"; then
|
||||
return 1
|
||||
fi
|
||||
|
||||
TOTP_SECRET="$(/usr/bin/jq -r ".totp_secret" <<<"$REST_OUTPUT")"
|
||||
TOTP_SECRET="$(/usr/bin/jq -r ".new_mfa.totp.secret" <<<"$REST_OUTPUT")"
|
||||
if [ $? -ne 0 ]; then
|
||||
record "Unable to obtain setup totp secret - is 'jq' installed?"
|
||||
return 2
|
||||
@ -255,9 +267,9 @@ mgmt_totp_enable() {
|
||||
return 2
|
||||
fi
|
||||
|
||||
# 3. enable TOTP
|
||||
# 2. enable TOTP
|
||||
record "Enabling TOTP using the secret and token"
|
||||
if ! mgmt_rest_as_user "POST" "/admin/mfa/totp/enable" "$user" "$pw" "secret=$TOTP_SECRET" "token=$TOTP_TOKEN"; then
|
||||
if ! mgmt_rest_as_user "POST" "/admin/mfa/totp/enable" "$user" "$pw" "secret=$TOTP_SECRET" "token=$TOTP_TOKEN" "label=$label"; then
|
||||
REST_ERROR="Failed: POST /admin/mfa/totp/enable: ${REST_ERROR}"
|
||||
return 1
|
||||
else
|
||||
@ -288,13 +300,41 @@ mgmt_assert_totp_enable() {
|
||||
}
|
||||
|
||||
|
||||
mgmt_totp_disable() {
|
||||
mgmt_mfa_disable() {
|
||||
# returns:
|
||||
# 0: success
|
||||
# 1: a REST error occurred, message in REST_ERROR
|
||||
# 2: some system error occured
|
||||
# 3: mfa is not configured for the user specified
|
||||
local user="$1"
|
||||
local pw="$2"
|
||||
record "[Disable TOTP for $user]"
|
||||
if ! mgmt_rest_as_user "POST" "/admin/mfa/totp/disable" "$user" "$pw"
|
||||
local mfa_id="$3"
|
||||
|
||||
record "[Disable MFA for $user]"
|
||||
if [ "$mfa_id" == "all" ]; then
|
||||
mfa_id=""
|
||||
elif [ "$mfa_id" == "" ]; then
|
||||
# get first mfa-id
|
||||
if ! mgmt_mfa_status "$user" "$pw"; then
|
||||
return 1
|
||||
fi
|
||||
|
||||
mfa_id="$(/usr/bin/jq -r ".enabled_mfa[0].id" <<<"$REST_OUTPUT")"
|
||||
if [ $? -ne 0 ]; then
|
||||
record "Unable to use /usr/bin/jq - is it installed?"
|
||||
return 2
|
||||
fi
|
||||
if [ "$mfa_id" == "null" ]; then
|
||||
record "No enabled mfa found at .enabled_mfa[0].id"
|
||||
return 3
|
||||
fi
|
||||
fi
|
||||
|
||||
|
||||
|
||||
if ! mgmt_rest_as_user "POST" "/admin/mfa/disable" "$user" "$pw" "mfa-id=$mfa_id"
|
||||
then
|
||||
REST_ERROR="Failed: POST /admin/mfa/totp/disable: $REST_ERROR"
|
||||
REST_ERROR="Failed: POST /admin/mfa/disable: $REST_ERROR"
|
||||
return 1
|
||||
else
|
||||
record "Success"
|
||||
@ -302,12 +342,12 @@ mgmt_totp_disable() {
|
||||
fi
|
||||
}
|
||||
|
||||
mgmt_assert_totp_disable() {
|
||||
mgmt_assert_mfa_disable() {
|
||||
local user="$1"
|
||||
mgmt_totp_disable "$@"
|
||||
mgmt_mfa_disable "$@"
|
||||
local code=$?
|
||||
if [ $code -ne 0 ]; then
|
||||
test_failure "Unable to disable TOTP for $user: $REST_ERROR"
|
||||
test_failure "Unable to disable MFA for $user: $REST_ERROR"
|
||||
return 1
|
||||
fi
|
||||
get_attribute "$LDAP_USERS_BASE" "(&(mail=$user)(objectClass=totpUser))" "dn"
|
||||
|
@ -3,16 +3,16 @@
|
||||
# Access assertions:
|
||||
# service accounts, except management:
|
||||
# can bind but not change passwords, including their own
|
||||
# can read all attributes of all users but not userPassword, totpSecret, totpMruToken
|
||||
# can read all attributes of all users but not userPassword, totpSecret, totpMruToken, totpLabel
|
||||
# can not write any user attributes, including shadowLastChange
|
||||
# can read config subtree (permitted-senders, domains)
|
||||
# no access to services subtree, except their own dn
|
||||
# users:
|
||||
# can bind and change their own password
|
||||
# can read and change their own shadowLastChange
|
||||
# no read or write access to user's own totpSecret or totpMruToken
|
||||
# no read or write access to user's own totpSecret, totpMruToken or totpLabel
|
||||
# can read attributess of all users except:
|
||||
# mailaccess, totpSecret, totpMruToken
|
||||
# mailaccess, totpSecret, totpMruToken, totpLabel
|
||||
# no access to config subtree
|
||||
# no access to services subtree
|
||||
# other:
|
||||
@ -38,24 +38,25 @@ test_user_change_password() {
|
||||
|
||||
|
||||
test_user_access() {
|
||||
# 1. can read attributess of all users except mailaccess, totpSecret, totpMruToken
|
||||
# 1. can read attributess of all users except mailaccess, totpSecret, totpMruToken, totpLabel
|
||||
# 2. can read and change their own shadowLastChange
|
||||
# 3. no access to config subtree
|
||||
# 4. no access to services subtree
|
||||
# 5. no read or write access to own totpSecret or totpMruToken
|
||||
# 5. no read or write access to own totpSecret, totpMruToken, or totpLabel
|
||||
|
||||
test_start "user-access"
|
||||
|
||||
local totpSecret="12345678901234567890"
|
||||
local totpMruToken="94287082"
|
||||
local totpLabel="my phone"
|
||||
|
||||
# create regular user's alice and bob
|
||||
local alice="alice@somedomain.com"
|
||||
create_user "alice@somedomain.com" "alice" "" "$totpSecret,$totpMruToken"
|
||||
create_user "alice@somedomain.com" "alice" "" "$totpSecret,$totpMruToken,$totpLabel"
|
||||
local alice_dn="$ATTR_DN"
|
||||
|
||||
local bob="bob@somedomain.com"
|
||||
create_user "bob@somedomain.com" "bob" "" "$totpSecret,$totpMruToken"
|
||||
create_user "bob@somedomain.com" "bob" "" "$totpSecret,$totpMruToken,$totpLabel"
|
||||
local bob_dn="$ATTR_DN"
|
||||
|
||||
# alice should be able to set her own shadowLastChange
|
||||
@ -64,27 +65,27 @@ test_user_access() {
|
||||
# test that alice can read her own attributes
|
||||
assert_r_access "$alice_dn" "$alice_dn" "alice" read mail maildrop cn sn shadowLastChange
|
||||
|
||||
# alice should not have access to her own mailaccess, totpSecret or totpMruToken, though
|
||||
assert_r_access "$alice_dn" "$alice_dn" "alice" no-read mailaccess totpSecret totpMruToken
|
||||
# alice should not have access to her own mailaccess, totpSecret, totpMruToken or totpLabel, though
|
||||
assert_r_access "$alice_dn" "$alice_dn" "alice" no-read mailaccess totpSecret totpMruToken totpLabel
|
||||
|
||||
# test that alice cannot change her own select attributes
|
||||
assert_w_access "$alice_dn" "$alice_dn" "alice"
|
||||
|
||||
# test that alice cannot change her own totpSecret or totpMruToken
|
||||
assert_w_access "$alice_dn" "$alice_dn" "alice" no-write "totpSecret=ABC" "totpMruToken=123456"
|
||||
# test that alice cannot change her own totpSecret, totpMruToken or totpLabel
|
||||
assert_w_access "$alice_dn" "$alice_dn" "alice" no-write "totpSecret=ABC" "totpMruToken=123456" "totpLabel=x-phone"
|
||||
|
||||
|
||||
# test that alice can read bob's attributes
|
||||
assert_r_access "$bob_dn" "$alice_dn" "alice" read mail maildrop cn sn
|
||||
|
||||
# alice should not have access to bob's mailaccess, totpSecret, or totpMruToken
|
||||
assert_r_access "$bob_dn" "$alice_dn" "alice" no-read mailaccess totpSecret totpMruToken
|
||||
# alice should not have access to bob's mailaccess, totpSecret, totpMruToken, or totpLabel
|
||||
assert_r_access "$bob_dn" "$alice_dn" "alice" no-read mailaccess totpSecret totpMruToken totpLabel
|
||||
|
||||
# test that alice cannot change bob's select attributes
|
||||
assert_w_access "$bob_dn" "$alice_dn" "alice"
|
||||
|
||||
# test that alice cannot change bob's attributes
|
||||
assert_w_access "$bob_dn" "$alice_dn" "alice" no-write "totpSecret=ABC" "totpMruToken=123456"
|
||||
assert_w_access "$bob_dn" "$alice_dn" "alice" no-write "totpSecret=ABC" "totpMruToken=123456" "totpLabel=x-phone"
|
||||
|
||||
|
||||
# test that alice cannot read a service account's attributes
|
||||
@ -151,10 +152,11 @@ test_service_access() {
|
||||
|
||||
local totpSecret="12345678901234567890"
|
||||
local totpMruToken="94287082"
|
||||
local totpLabel="my phone"
|
||||
|
||||
# create regular user with password "alice"
|
||||
local alice="alice@somedomain.com"
|
||||
create_user "alice@somedomain.com" "alice" "" "$totpSecret,$totpMruToken"
|
||||
create_user "alice@somedomain.com" "alice" "" "$totpSecret,$totpMruToken,$totpLabel"
|
||||
|
||||
# create a test service account
|
||||
create_service_account "test" "test"
|
||||
@ -174,12 +176,12 @@ test_service_access() {
|
||||
# check that service account can read user attributes
|
||||
assert_r_access "$alice_dn" "$LDAP_POSTFIX_DN" "$LDAP_POSTFIX_PASSWORD" read mail maildrop uid cn sn shadowLastChange
|
||||
|
||||
# service account should not be able to read user's userPassword, totpSecret or totpMruToken
|
||||
assert_r_access "$alice_dn" "$LDAP_POSTFIX_DN" "$LDAP_POSTFIX_PASSWORD" no-read userPassword totpSecret totpMruToken
|
||||
# service account should not be able to read user's userPassword, totpSecret, totpMruToken, or totpLabel
|
||||
assert_r_access "$alice_dn" "$LDAP_POSTFIX_DN" "$LDAP_POSTFIX_PASSWORD" no-read userPassword totpSecret totpMruToken totpLabel
|
||||
|
||||
# service accounts cannot change user attributes
|
||||
assert_w_access "$alice_dn" "$LDAP_POSTFIX_DN" "$LDAP_POSTFIX_PASSWORD"
|
||||
assert_w_access "$alice_dn" "$LDAP_POSTFIX_DN" "$LDAP_POSTFIX_PASSWORD" no-write "shadowLastChange=1" "totpSecret=ABC" "totpMruToken=333333"
|
||||
assert_w_access "$alice_dn" "$LDAP_POSTFIX_DN" "$LDAP_POSTFIX_PASSWORD" no-write "shadowLastChange=1" "totpSecret=ABC" "totpMruToken=333333" "totpLabel=x-phone"
|
||||
fi
|
||||
|
||||
# service accounts can read config subtree (permitted-senders, domains)
|
||||
|
@ -215,8 +215,12 @@ test_totp() {
|
||||
|
||||
# alice must be admin to use TOTP
|
||||
if ! have_test_failures; then
|
||||
if mgmt_totp_enable "$alice" "$alice_pw"; then
|
||||
test_failure "User must be an admin to use TOTP, but server allowed it"
|
||||
else
|
||||
mgmt_assert_privileges_add "$alice" "admin"
|
||||
fi
|
||||
fi
|
||||
|
||||
# add totp to alice's account (if successful, secret is in TOTP_SECRET)
|
||||
if ! have_test_failures; then
|
||||
@ -227,7 +231,7 @@ test_totp() {
|
||||
# logging in with just the password should now fail
|
||||
if ! have_test_failures; then
|
||||
record "Expect a login failure..."
|
||||
mgmt_assert_admin_me "$alice" "$alice_pw" "missing_token"
|
||||
mgmt_assert_admin_me "$alice" "$alice_pw" "missing-totp-token"
|
||||
fi
|
||||
|
||||
|
||||
@ -251,7 +255,7 @@ test_totp() {
|
||||
|
||||
# ensure the totpMruToken was changed in LDAP
|
||||
get_attribute "$LDAP_USERS_BASE" "(mail=$alice)" "totpMruToken"
|
||||
if [ "$ATTR_VALUE" != "$TOTP_TOKEN" ]; then
|
||||
if [ "$ATTR_VALUE" != "{0}$TOTP_TOKEN" ]; then
|
||||
record_search "(mail=$alice)"
|
||||
test_failure "totpMruToken wasn't updated in LDAP"
|
||||
fi
|
||||
@ -268,7 +272,7 @@ test_totp() {
|
||||
# disable totp on the account - login should work with just the password
|
||||
# and the ldap entry should not have the 'totpUser' objectClass
|
||||
if ! have_test_failures; then
|
||||
if mgmt_assert_totp_disable "$alice" "$api_key"; then
|
||||
if mgmt_assert_mfa_disable "$alice" "$api_key"; then
|
||||
mgmt_assert_admin_me "$alice" "$alice_pw" "ok"
|
||||
fi
|
||||
fi
|
||||
|
@ -7,3 +7,4 @@
|
||||
TEST_USER="totp_admin@$(email_domainpart "$EMAIL_ADDR")"
|
||||
TEST_USER_PASS="$(static_qa_password)"
|
||||
TEST_USER_TOTP_SECRET="6VXVWOSCY7JLU4VBZ6LQEJSBN6WYWECU"
|
||||
TEST_USER_TOTP_LABEL="my phone"
|
||||
|
@ -27,7 +27,8 @@ then
|
||||
fi
|
||||
|
||||
# enable totp
|
||||
if ! rest_urlencoded POST "${url%/}/admin/mfa/totp/enable" "$TEST_USER" "$TEST_USER_PASS" --insecure "secret=$TEST_USER_TOTP_SECRET" "token=$(totp_current_token "$TEST_USER_TOTP_SECRET")" 2>/dev/null; then
|
||||
token="$(totp_current_token "$TEST_USER_TOTP_SECRET")"
|
||||
if ! rest_urlencoded POST "${url%/}/admin/mfa/totp/enable" "$TEST_USER" "$TEST_USER_PASS" --insecure "secret=$TEST_USER_TOTP_SECRET" "token=$token" "label=$TEST_USER_TOTP_LABEL" 2>/dev/null; then
|
||||
echo "Unable to enable TOTP. err=$REST_ERROR" 1>&2
|
||||
exit 1
|
||||
fi
|
||||
|
@ -27,7 +27,7 @@ if [ -z "$ATTR_DN" ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "$ATTR_VALUE" != "$TEST_USER_TOTP_SECRET" ]; then
|
||||
if [ "$ATTR_VALUE" != "{0}$TEST_USER_TOTP_SECRET" ]; then
|
||||
echo "totpSecret mismatch"
|
||||
exit 1
|
||||
fi
|
||||
|
Loading…
Reference in New Issue
Block a user