Reorganize the MFA backend methods
This commit is contained in:
parent
7d6427904f
commit
a8ea456b49
7 changed files with 200 additions and 222 deletions
|
@ -1,9 +1,10 @@
|
||||||
import base64, os, os.path, hmac
|
import base64, os, os.path, hmac, json
|
||||||
|
|
||||||
from flask import make_response
|
from flask import make_response
|
||||||
|
|
||||||
import utils, totp
|
import utils
|
||||||
from mailconfig import get_mail_password, get_mail_user_privileges, get_mfa_state
|
from mailconfig import 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_KEY_PATH = '/var/lib/mailinabox/api.key'
|
||||||
DEFAULT_AUTH_REALM = 'Mail-in-a-Box Management Server'
|
DEFAULT_AUTH_REALM = 'Mail-in-a-Box Management Server'
|
||||||
|
@ -72,40 +73,29 @@ class KeyAuthService:
|
||||||
if username in (None, ""):
|
if username in (None, ""):
|
||||||
raise ValueError("Authorization header invalid.")
|
raise ValueError("Authorization header invalid.")
|
||||||
elif username == self.key:
|
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"])
|
return (None, ["admin"])
|
||||||
else:
|
else:
|
||||||
# The user is trying to log in with a username and user-specific
|
# The user is trying to log in with a username and either a password
|
||||||
# API key or password. Raises or returns privs and an indicator
|
# (and possibly a MFA token) or a user-specific API key.
|
||||||
# whether the user is using their password or a user-specific API-key.
|
return (username, self.check_user_auth(username, password, request, env))
|
||||||
privs, is_user_key = self.get_user_credentials(username, password, env)
|
|
||||||
|
|
||||||
# If the user is using their API key to login, 2FA has been passed before
|
def check_user_auth(self, email, pw, request, env):
|
||||||
if is_user_key:
|
# Validate a user's login email address and password. If MFA is enabled,
|
||||||
return (username, privs)
|
# check the MFA token in the X-Auth-Token header.
|
||||||
|
#
|
||||||
totp_strategy = totp.TOTPStrategy(email=username)
|
# On success returns a list of privileges (e.g. [] or ['admin']). On login
|
||||||
# this will raise `totp.MissingTokenError` or `totp.BadTokenError` for bad requests
|
# failure, raises a ValueError with a login error message.
|
||||||
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.
|
|
||||||
|
|
||||||
# Sanity check.
|
# Sanity check.
|
||||||
if email == "" or pw == "":
|
if email == "" or pw == "":
|
||||||
raise ValueError("Enter an email address and password.")
|
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
|
# The password might be a user-specific API key. create_user_key raises
|
||||||
# a ValueError if the user does not exist.
|
# a ValueError if the user does not exist.
|
||||||
if hmac.compare_digest(self.create_user_key(email, env), pw):
|
if hmac.compare_digest(self.create_user_key(email, env), pw):
|
||||||
# OK.
|
# OK.
|
||||||
is_user_key = True
|
pass
|
||||||
else:
|
else:
|
||||||
# Get the hashed password of the user. Raise a ValueError if the
|
# Get the hashed password of the user. Raise a ValueError if the
|
||||||
# email address does not correspond to a user.
|
# email address does not correspond to a user.
|
||||||
|
@ -125,6 +115,12 @@ class KeyAuthService:
|
||||||
# Login failed.
|
# Login failed.
|
||||||
raise ValueError("Invalid password.")
|
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
|
# 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
|
# 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.
|
# return a tuple of an error message and an HTTP status code.
|
||||||
|
@ -132,26 +128,29 @@ class KeyAuthService:
|
||||||
if isinstance(privs, tuple): raise ValueError(privs[0])
|
if isinstance(privs, tuple): raise ValueError(privs[0])
|
||||||
|
|
||||||
# Return a list of privileges.
|
# Return a list of privileges.
|
||||||
return (privs, is_user_key)
|
return privs
|
||||||
|
|
||||||
def create_user_key(self, email, env):
|
def create_user_key(self, email, env):
|
||||||
# Store an HMAC with the client. The hashed message of the HMAC will be the user's
|
# Create a user API key, which is a shared secret that we can re-generate from
|
||||||
# email address & hashed password and the key will be the master API key. If TOTP
|
# static information in our database. The shared secret contains the user's
|
||||||
# is active, the key will also include the TOTP secret. The user of course has their
|
# email address, current hashed password, and current MFA state, so that the
|
||||||
# own email address and password. We assume they do not have the master API key
|
# key becomes invalid if any of that information changes.
|
||||||
# (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
|
# Use an HMAC to generate the API key using our master API key as a key,
|
||||||
# a user's password is reset, the HMAC changes and they will correctly need to log
|
# which also means that the API key becomes invalid when our master API key
|
||||||
# in to the control panel again. This method raises a ValueError if the user does
|
# changes --- i.e. when this process is restarted.
|
||||||
# not exist, due to get_mail_password.
|
#
|
||||||
|
# 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" " + get_mail_password(email, env).encode("utf8")
|
msg = b"AUTH:" + email.encode("utf8") + b" " + 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')
|
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()
|
return hmac.new(hash_key, msg, digestmod="sha256").hexdigest()
|
||||||
|
|
||||||
def _generate_key(self):
|
def _generate_key(self):
|
||||||
|
|
|
@ -5,11 +5,11 @@ from functools import wraps
|
||||||
|
|
||||||
from flask import Flask, request, render_template, abort, Response, send_from_directory, make_response
|
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, remove_mail_user
|
from mailconfig import get_mail_users, get_mail_users_ex, get_admins, add_mail_user, set_mail_password, remove_mail_user
|
||||||
from mailconfig import get_mail_user_privileges, add_remove_mail_user_privilege
|
from mailconfig import get_mail_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_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, provision_totp, validate_totp_secret, enable_mfa, disable_mfa
|
||||||
|
|
||||||
env = utils.load_environment()
|
env = utils.load_environment()
|
||||||
|
|
||||||
|
@ -36,30 +36,31 @@ app = Flask(__name__, template_folder=os.path.abspath(os.path.join(os.path.dirna
|
||||||
def authorized_personnel_only(viewfunc):
|
def authorized_personnel_only(viewfunc):
|
||||||
@wraps(viewfunc)
|
@wraps(viewfunc)
|
||||||
def newview(*args, **kwargs):
|
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
|
error = None
|
||||||
privs = []
|
privs = []
|
||||||
|
|
||||||
try:
|
try:
|
||||||
email, privs = auth_service.authenticate(request, env)
|
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:
|
except ValueError as e:
|
||||||
# Write a line in the log recording the failed login
|
# Write a line in the log recording the failed login
|
||||||
log_failed_login(request)
|
log_failed_login(request)
|
||||||
|
|
||||||
# Authentication failed.
|
# Authentication failed.
|
||||||
error = "Incorrect username or password"
|
error = str(e)
|
||||||
|
|
||||||
# Authorized to access an API view?
|
# Authorized to access an API view?
|
||||||
if "admin" in privs:
|
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.
|
# Call view func.
|
||||||
return viewfunc(*args, **kwargs)
|
return viewfunc(*args, **kwargs)
|
||||||
elif not error:
|
|
||||||
|
if not error:
|
||||||
error = "You are not an administrator."
|
error = "You are not an administrator."
|
||||||
|
|
||||||
# Not authorized. Return a 401 (send auth) and a prompt to authorize by default.
|
# Not authorized. Return a 401 (send auth) and a prompt to authorize by default.
|
||||||
|
@ -126,27 +127,12 @@ def me():
|
||||||
# Is the caller authorized?
|
# Is the caller authorized?
|
||||||
try:
|
try:
|
||||||
email, privs = auth_service.authenticate(request, env)
|
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:
|
except ValueError as e:
|
||||||
# Log the failed login
|
# Log the failed login
|
||||||
log_failed_login(request)
|
log_failed_login(request)
|
||||||
|
|
||||||
return json_response({
|
return json_response({
|
||||||
"status": "invalid",
|
"status": "invalid",
|
||||||
"reason": "Incorrect username or password",
|
"reason": str(e),
|
||||||
})
|
})
|
||||||
|
|
||||||
resp = {
|
resp = {
|
||||||
|
@ -409,47 +395,33 @@ def ssl_provision_certs():
|
||||||
|
|
||||||
@app.route('/mfa/status', methods=['GET'])
|
@app.route('/mfa/status', methods=['GET'])
|
||||||
@authorized_personnel_only
|
@authorized_personnel_only
|
||||||
def two_factor_auth_get_status():
|
def mfa_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)
|
|
||||||
|
|
||||||
return json_response({
|
return json_response({
|
||||||
"type": None,
|
"enabled_mfa": get_mfa_state(request.user_email, env),
|
||||||
"totp_secret": secret,
|
"new_mfa": {
|
||||||
"totp_qr": secret_qr
|
"totp": provision_totp(request.user_email, env)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@app.route('/mfa/totp/enable', methods=['POST'])
|
@app.route('/mfa/totp/enable', methods=['POST'])
|
||||||
@authorized_personnel_only
|
@authorized_personnel_only
|
||||||
def totp_post_enable():
|
def totp_post_enable():
|
||||||
email, _ = auth_service.authenticate(request, env)
|
|
||||||
|
|
||||||
secret = request.form.get('secret')
|
secret = request.form.get('secret')
|
||||||
token = request.form.get('token')
|
token = request.form.get('token')
|
||||||
|
if type(token) != str:
|
||||||
if type(secret) != str or type(token) != str or len(token) != 6 or len(secret) != 32:
|
|
||||||
return json_response({ "error": 'bad_input' }, 400)
|
return json_response({ "error": 'bad_input' }, 400)
|
||||||
|
try:
|
||||||
|
validate_totp_secret(secret)
|
||||||
|
enable_mfa(request.user_email, "totp", secret, token, env)
|
||||||
|
except ValueError as e:
|
||||||
|
return str(e)
|
||||||
|
return "OK"
|
||||||
|
|
||||||
if totp.validate(secret, token):
|
@app.route('/mfa/disable', methods=['POST'])
|
||||||
create_totp_credential(email, secret, env)
|
|
||||||
return json_response({})
|
|
||||||
|
|
||||||
return json_response({ "error": 'token_mismatch' }, 400)
|
|
||||||
|
|
||||||
@app.route('/mfa/totp/disable', methods=['POST'])
|
|
||||||
@authorized_personnel_only
|
@authorized_personnel_only
|
||||||
def totp_post_disable():
|
def totp_post_disable():
|
||||||
email, _ = auth_service.authenticate(request, env)
|
disable_mfa(request.user_email, request.form.get('mfa-id'), env)
|
||||||
delete_totp_credential(email, env)
|
return "OK"
|
||||||
return json_response({})
|
|
||||||
|
|
||||||
# WEB
|
# WEB
|
||||||
|
|
||||||
|
|
|
@ -547,49 +547,6 @@ def get_required_aliases(env):
|
||||||
|
|
||||||
return aliases
|
return aliases
|
||||||
|
|
||||||
# multi-factor auth
|
|
||||||
|
|
||||||
def get_mfa_state(email, env):
|
|
||||||
c = open_database(env)
|
|
||||||
c.execute('SELECT secret, mru_token FROM totp_credentials WHERE user_email=?', (email,))
|
|
||||||
|
|
||||||
credential_row = c.fetchone()
|
|
||||||
if credential_row is None:
|
|
||||||
return { 'type': None }
|
|
||||||
|
|
||||||
secret, mru_token = credential_row
|
|
||||||
|
|
||||||
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, c = open_database(env, with_connection=True)
|
|
||||||
c.execute('INSERT INTO totp_credentials (user_email, secret) VALUES (?, ?)', (email, secret))
|
|
||||||
conn.commit()
|
|
||||||
return "OK"
|
|
||||||
|
|
||||||
def set_mru_totp_code(email, token, env):
|
|
||||||
conn, c = open_database(env, with_connection=True)
|
|
||||||
c.execute('UPDATE totp_credentials SET mru_token=? WHERE user_email=?', (token, email))
|
|
||||||
|
|
||||||
if c.rowcount != 1:
|
|
||||||
conn.close()
|
|
||||||
raise ValueError("That's not a user (%s)." % email)
|
|
||||||
|
|
||||||
conn.commit()
|
|
||||||
return "OK"
|
|
||||||
|
|
||||||
def delete_totp_credential(email, env):
|
|
||||||
conn, c = open_database(env, with_connection=True)
|
|
||||||
c.execute('DELETE FROM totp_credentials WHERE user_email=?', (email,))
|
|
||||||
conn.commit()
|
|
||||||
return "OK"
|
|
||||||
|
|
||||||
def kick(env, mail_result=None):
|
def kick(env, mail_result=None):
|
||||||
results = []
|
results = []
|
||||||
|
|
||||||
|
@ -651,12 +608,6 @@ def validate_password(pw):
|
||||||
if len(pw) < 8:
|
if len(pw) < 8:
|
||||||
raise ValueError("Passwords must be at least eight characters.")
|
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__":
|
if __name__ == "__main__":
|
||||||
import sys
|
import sys
|
||||||
if len(sys.argv) > 2 and sys.argv[1] == "validate-email":
|
if len(sys.argv) > 2 and sys.argv[1] == "validate-email":
|
||||||
|
|
126
management/mfa.py
Normal file
126
management/mfa.py
Normal file
|
@ -0,0 +1,126 @@
|
||||||
|
import base64
|
||||||
|
import hmac
|
||||||
|
import io
|
||||||
|
import os
|
||||||
|
import pyotp
|
||||||
|
import qrcode
|
||||||
|
|
||||||
|
from mailconfig import open_database
|
||||||
|
|
||||||
|
def get_user_id(email, c):
|
||||||
|
c.execute('SELECT id FROM users WHERE email=?', (email,))
|
||||||
|
r = c.fetchone()
|
||||||
|
if not r: raise ValueError("User does not exist.")
|
||||||
|
return r[0]
|
||||||
|
|
||||||
|
def get_mfa_state(email, env):
|
||||||
|
c = open_database(env)
|
||||||
|
c.execute('SELECT id, type, secret, mru_token FROM mfa WHERE user_id=?', (get_user_id(email, c),))
|
||||||
|
return [
|
||||||
|
{ "id": r[0], "type": r[1], "secret": r[2], "mru_token": r[3] }
|
||||||
|
for r in c.fetchall()
|
||||||
|
]
|
||||||
|
|
||||||
|
def enable_mfa(email, type, secret, token, env):
|
||||||
|
if type == "totp":
|
||||||
|
validate_totp_secret(secret)
|
||||||
|
# Sanity check with the provide current token.
|
||||||
|
totp = pyotp.TOTP(secret)
|
||||||
|
if not totp.verify(token, valid_window=1):
|
||||||
|
raise ValueError("Invalid token.")
|
||||||
|
else:
|
||||||
|
raise ValueError("Invalid MFA type.")
|
||||||
|
|
||||||
|
conn, c = open_database(env, with_connection=True)
|
||||||
|
c.execute('INSERT INTO mfa (user_id, type, secret) VALUES (?, ?, ?)', (get_user_id(email, c), type, secret))
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
def set_mru_token(email, token, env):
|
||||||
|
conn, c = open_database(env, with_connection=True)
|
||||||
|
c.execute('UPDATE mfa SET mru_token=? WHERE user_id=?', (token, get_user_id(email, c)))
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
def disable_mfa(email, mfa_id, env):
|
||||||
|
conn, c = open_database(env, with_connection=True)
|
||||||
|
if mfa_id is None:
|
||||||
|
# Disable all MFA for a user.
|
||||||
|
c.execute('DELETE FROM mfa WHERE user_id=?', (get_user_id(email, c),))
|
||||||
|
else:
|
||||||
|
# Disable a particular MFA mode for a user.
|
||||||
|
c.execute('DELETE FROM mfa WHERE user_id=? AND id=?', (get_user_id(email, c), mfa_id))
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
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")
|
||||||
|
|
||||||
|
def provision_totp(email, env):
|
||||||
|
# Make a new secret.
|
||||||
|
secret = base64.b32encode(os.urandom(20)).decode('utf-8')
|
||||||
|
validate_totp_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_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":
|
||||||
|
# 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:
|
||||||
|
hints.add("missing-totp-token")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Check for a replay attack.
|
||||||
|
if hmac.compare_digest(token, mfa_mode['mru_token'] or ""):
|
||||||
|
# If the token fails, skip this MFA mode.
|
||||||
|
hints.add("invalid-totp-token")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Check the token.
|
||||||
|
totp = pyotp.TOTP(mfa_mode["secret"])
|
||||||
|
if not totp.verify(token, valid_window=1):
|
||||||
|
hints.add("invalid-totp-token")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# On success, record the token to prevent a replay attack.
|
||||||
|
set_mru_token(email, token, env)
|
||||||
|
return (True, [])
|
||||||
|
|
||||||
|
# On a failed login, indicate failure and any hints for what the user can do instead.
|
||||||
|
return (False, list(hints))
|
|
@ -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
|
|
|
@ -22,7 +22,7 @@ if [ ! -f $db_path ]; then
|
||||||
echo Creating new user database: $db_path;
|
echo Creating new user database: $db_path;
|
||||||
echo "CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, email TEXT NOT NULL UNIQUE, password TEXT NOT NULL, extra, privileges TEXT NOT NULL DEFAULT '');" | sqlite3 $db_path;
|
echo "CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, email TEXT NOT NULL UNIQUE, password TEXT NOT NULL, extra, privileges TEXT NOT NULL DEFAULT '');" | sqlite3 $db_path;
|
||||||
echo "CREATE TABLE aliases (id INTEGER PRIMARY KEY AUTOINCREMENT, source TEXT NOT NULL UNIQUE, destination TEXT NOT NULL, permitted_senders TEXT);" | sqlite3 $db_path;
|
echo "CREATE TABLE aliases (id INTEGER PRIMARY KEY AUTOINCREMENT, source TEXT NOT NULL UNIQUE, destination TEXT NOT NULL, permitted_senders TEXT);" | sqlite3 $db_path;
|
||||||
echo "CREATE TABLE 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);" | sqlite3 $db_path;
|
echo "CREATE TABLE IF NOT EXISTS mfa (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL UNIQUE, type TEXT NOT NULL, secret TEXT NOT NULL, mru_token TEXT, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE);" | sqlite3 $db_path;
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# ### User Authentication
|
# ### User Authentication
|
||||||
|
|
|
@ -182,9 +182,11 @@ def migration_12(env):
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
def migration_13(env):
|
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')
|
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 IF NOT EXISTS mfa (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL UNIQUE, type TEXT NOT NULL, secret TEXT NOT NULL, mru_token TEXT, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE);"])
|
||||||
|
|
||||||
|
###########################################################
|
||||||
|
|
||||||
def get_current_migration():
|
def get_current_migration():
|
||||||
ver = 0
|
ver = 0
|
||||||
|
|
Loading…
Reference in a new issue