From a7a66929aac237777c5916eee9c0f86eeac53735 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Felix=20Sp=C3=B6ttel?=
<1682504+fspoettel@users.noreply.github.com>
Date: Wed, 2 Sep 2020 16:48:23 +0200
Subject: [PATCH 01/32] add user interface for managing 2fa
* update user schema with 2fa columns
---
management/daemon.py | 55 +++++-
management/mailconfig.py | 40 ++++
management/templates/index.html | 7 +-
management/templates/two-factor-auth.html | 220 ++++++++++++++++++++++
management/totp.py | 51 +++++
setup/mail-users.sh | 3 +-
setup/management.sh | 1 +
7 files changed, 370 insertions(+), 7 deletions(-)
create mode 100644 management/templates/two-factor-auth.html
create mode 100644 management/totp.py
diff --git a/management/daemon.py b/management/daemon.py
index b7bf2a66..ebf112f6 100755
--- a/management/daemon.py
+++ b/management/daemon.py
@@ -1,14 +1,15 @@
import os, os.path, re, json, time
-import subprocess
+import multiprocessing.pool, subprocess
from functools import wraps
from flask import Flask, request, render_template, abort, Response, send_from_directory, make_response
-import auth, utils, multiprocessing.pool
+import auth, utils, totp
from mailconfig import get_mail_users, get_mail_users_ex, get_admins, add_mail_user, set_mail_password, remove_mail_user
from mailconfig import get_mail_user_privileges, add_remove_mail_user_privilege
from mailconfig import get_mail_aliases, get_mail_aliases_ex, get_mail_domains, add_mail_alias, remove_mail_alias
+from mailconfig import get_two_factor_info, set_two_factor_secret, remove_two_factor_secret
env = utils.load_environment()
@@ -83,8 +84,8 @@ def authorized_personnel_only(viewfunc):
def unauthorized(error):
return auth_service.make_unauthorized_response()
-def json_response(data):
- return Response(json.dumps(data, indent=2, sort_keys=True)+'\n', status=200, mimetype='application/json')
+def json_response(data, status=200):
+ return Response(json.dumps(data, indent=2, sort_keys=True)+'\n', status=status, mimetype='application/json')
###################################
@@ -334,7 +335,7 @@ def ssl_get_status():
# What domains can we provision certificates for? What unexpected problems do we have?
provision, cant_provision = get_certificates_to_provision(env, show_valid_certs=False)
-
+
# What's the current status of TLS certificates on all of the domain?
domains_status = get_web_domains_info(env)
domains_status = [
@@ -383,6 +384,50 @@ def ssl_provision_certs():
requests = provision_certificates(env, limit_domains=None)
return json_response({ "requests": requests })
+# Two Factor Auth
+
+@app.route('/two-factor-auth/status', methods=['GET'])
+@authorized_personnel_only
+def two_factor_auth_get_status():
+ email, privs = auth_service.authenticate(request, env)
+ two_factor_secret, two_factor_token = get_two_factor_info(email, env)
+
+ if two_factor_secret != None:
+ return json_response({ 'status': 'on' })
+
+ secret = totp.get_secret()
+ secret_url = totp.get_otp_uri(secret, email)
+ secret_qr = totp.get_qr_code(secret_url)
+
+ return json_response({
+ "status": 'off',
+ "secret": secret,
+ "qr_code": secret_qr
+ })
+
+@app.route('/two-factor-auth/setup', methods=['POST'])
+@authorized_personnel_only
+def two_factor_auth_post_setup():
+ email, privs = 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:
+ return json_response({ "error": 'bad_input' }, 400)
+
+ if (totp.validate(secret, token)):
+ set_two_factor_secret(email, secret, token, env)
+ return json_response({})
+
+ return json_response({ "error": 'token_mismatch' }, 400)
+
+@app.route('/two-factor-auth/disable', methods=['POST'])
+@authorized_personnel_only
+def two_factor_auth_post_disable():
+ email, privs = auth_service.authenticate(request, env)
+ remove_two_factor_secret(email, env)
+ return json_response({})
# WEB
diff --git a/management/mailconfig.py b/management/mailconfig.py
index b061ea7d..3bc48897 100755
--- a/management/mailconfig.py
+++ b/management/mailconfig.py
@@ -547,6 +547,41 @@ def get_required_aliases(env):
return aliases
+def get_two_factor_info(email, env):
+ c = open_database(env)
+
+ c.execute('SELECT two_factor_secret, two_factor_last_used_token FROM users WHERE email=?', (email,))
+ rows = c.fetchall()
+ if len(rows) != 1:
+ raise ValueError("That's not a user (%s)." % email)
+ return (rows[0][0], rows[0][1])
+
+def set_two_factor_secret(email, secret, token, env):
+ validate_two_factor_secret(secret)
+
+ conn, c = open_database(env, with_connection=True)
+ c.execute("UPDATE users SET two_factor_secret=?, two_factor_last_used_token=? WHERE email=?", (secret, token, email))
+ if c.rowcount != 1:
+ raise ValueError("That's not a user (%s)." % email)
+ conn.commit()
+ return "OK"
+
+def set_two_factor_last_used_token(email, token, env):
+ conn, c = open_database(env, with_connection=True)
+ c.execute("UPDATE users SET two_factor_last_used_token=? WHERE email=?", (token, email))
+ if c.rowcount != 1:
+ raise ValueError("That's not a user (%s)." % email)
+ conn.commit()
+ return "OK"
+
+def remove_two_factor_secret(email, env):
+ conn, c = open_database(env, with_connection=True)
+ c.execute("UPDATE users SET two_factor_secret=null, two_factor_last_used_token=null WHERE email=?", (email,))
+ if c.rowcount != 1:
+ raise ValueError("That's not a user (%s)." % email)
+ conn.commit()
+ return "OK"
+
def kick(env, mail_result=None):
results = []
@@ -608,6 +643,11 @@ def validate_password(pw):
if len(pw) < 8:
raise ValueError("Passwords must be at least eight characters.")
+def validate_two_factor_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
diff --git a/management/templates/index.html b/management/templates/index.html
index 2c0d5a9a..3088ef63 100644
--- a/management/templates/index.html
+++ b/management/templates/index.html
@@ -93,6 +93,7 @@
Custom DNS
External DNS
+ Two Factor Authentication
Munin Monitoring
@@ -131,7 +132,11 @@
{% include "custom-dns.html" %}
-
+
+ {% include "two-factor-auth.html" %}
+
+
+
{% include "login.html" %}
diff --git a/management/templates/two-factor-auth.html b/management/templates/two-factor-auth.html
new file mode 100644
index 00000000..9f1a8b5a
--- /dev/null
+++ b/management/templates/two-factor-auth.html
@@ -0,0 +1,220 @@
+
+
+
Two Factor Authentication
+
+
+
Loading...
+
+
+
+
+
+
+
+
+
diff --git a/management/totp.py b/management/totp.py
new file mode 100644
index 00000000..52cdc256
--- /dev/null
+++ b/management/totp.py
@@ -0,0 +1,51 @@
+import base64
+import hmac
+import io
+import os
+import struct
+import time
+from urllib.parse import quote
+import qrcode
+
+def get_secret():
+ return base64.b32encode(os.urandom(20)).decode('utf-8')
+
+def get_otp_uri(secret, email):
+ site_name = 'mailinabox'
+
+ return 'otpauth://totp/{}:{}?secret={}&issuer={}'.format(
+ quote(site_name),
+ quote(email),
+ secret,
+ quote(site_name)
+ )
+
+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
+ @see https://git.sr.ht/~sircmpwn/meta.sr.ht/tree/master/metasrht/totp.py
+ @see https://github.com/susam/mintotp/blob/master/mintotp.py
+ TODO: resynchronisation
+ """
+ key = base64.b32decode(secret)
+ tm = int(time.time() / 30)
+ digits = 6
+
+ step = 0
+ counter = struct.pack('>Q', tm + step)
+
+ hm = hmac.HMAC(key, counter, 'sha1').digest()
+ offset = hm[-1] &0x0F
+ binary = struct.unpack(">L", hm[offset:offset + 4])[0] & 0x7fffffff
+
+ code = str(binary)[-digits:].rjust(digits, '0')
+ return token == code
diff --git a/setup/mail-users.sh b/setup/mail-users.sh
index e54485bb..3047489b 100755
--- a/setup/mail-users.sh
+++ b/setup/mail-users.sh
@@ -20,7 +20,8 @@ db_path=$STORAGE_ROOT/mail/users.sqlite
# Create an empty database if it doesn't yet exist.
if [ ! -f $db_path ]; then
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;
+ # TODO: Add migration
+ echo "CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, email TEXT NOT NULL UNIQUE, password TEXT NOT NULL, extra, privileges TEXT NOT NULL DEFAULT '', two_factor_secret TEXT, two_factor_last_used_token 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;
fi
diff --git a/setup/management.sh b/setup/management.sh
index 4b398aa2..ce78b171 100755
--- a/setup/management.sh
+++ b/setup/management.sh
@@ -50,6 +50,7 @@ hide_output $venv/bin/pip install --upgrade pip
hide_output $venv/bin/pip install --upgrade \
rtyaml "email_validator>=1.0.0" "exclusiveprocess" \
flask dnspython python-dateutil \
+ qrcode[pil] \
"idna>=2.0.0" "cryptography==2.2.2" boto psutil postfix-mta-sts-resolver
# CONFIGURATION
From 3c3683429b0774bad9210829cbe0caf43d3dc14d Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Felix=20Sp=C3=B6ttel?=
<1682504+fspoettel@users.noreply.github.com>
Date: Wed, 2 Sep 2020 17:23:32 +0200
Subject: [PATCH 02/32] implement two factor check during login
---
management/auth.py | 48 +++++++++++++++++++---
management/daemon.py | 34 ++++++++++++++--
management/templates/index.html | 4 +-
management/templates/login.html | 70 ++++++++++++++++++++++++++-------
4 files changed, 130 insertions(+), 26 deletions(-)
diff --git a/management/auth.py b/management/auth.py
index 55f59664..83d9c1d6 100644
--- a/management/auth.py
+++ b/management/auth.py
@@ -2,12 +2,19 @@ import base64, os, os.path, hmac
from flask import make_response
-import utils
+import utils, totp
from mailconfig import get_mail_password, get_mail_user_privileges
+from mailconfig import get_two_factor_info, set_two_factor_last_used_token
DEFAULT_KEY_PATH = '/var/lib/mailinabox/api.key'
DEFAULT_AUTH_REALM = 'Mail-in-a-Box Management Server'
+class MissingTokenError(ValueError):
+ pass
+
+class BadTokenError(ValueError):
+ pass
+
class KeyAuthService:
"""Generate an API key for authenticating clients
@@ -76,23 +83,52 @@ class KeyAuthService:
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.
- return (username, self.get_user_credentials(username, password, env))
+ # 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)
+
+ # If the user is using their API key to login, 2FA has been passed before
+ if is_user_key:
+ return (username, privs)
+
+ secret, last_token = get_two_factor_info(username, env)
+
+ # 2FA is not enabled, we can skip further checks
+ if secret == "" or secret == None:
+ return (username, privs)
+
+ # 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 token_header == None or 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 token_header == last_token or totp.validate(secret, token_header) != True:
+ raise BadTokenError("Two factor code incorrect")
+
+ set_two_factor_last_used_token(username, token_header, 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.
+ # 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.
- pass
+ is_user_key = True
else:
# Get the hashed password of the user. Raise a ValueError if the
# email address does not correspond to a user.
@@ -119,7 +155,7 @@ class KeyAuthService:
if isinstance(privs, tuple): raise ValueError(privs[0])
# Return a list of privileges.
- return privs
+ return (privs, is_user_key)
def create_user_key(self, email, env):
# Store an HMAC with the client. The hashed message of the HMAC will be the user's
diff --git a/management/daemon.py b/management/daemon.py
index ebf112f6..b80b1e73 100755
--- a/management/daemon.py
+++ b/management/daemon.py
@@ -40,14 +40,23 @@ def authorized_personnel_only(viewfunc):
error = None
try:
email, privs = auth_service.authenticate(request, env)
+ except auth.MissingTokenError as e:
+ privs = []
+ error = str(e)
+ except auth.BadTokenError as e:
+ # Write a line in the log recording the failed login
+ log_failed_login(request)
+
+ privs = []
+ error = str(e)
except ValueError as e:
+ # Write a line in the log recording the failed login
+ log_failed_login(request)
+
# Authentication failed.
privs = []
error = "Incorrect username or password"
- # Write a line in the log recording the failed login
- log_failed_login(request)
-
# Authorized to access an API view?
if "admin" in privs:
# Call view func.
@@ -119,6 +128,23 @@ def me():
# Is the caller authorized?
try:
email, privs = auth_service.authenticate(request, env)
+ except auth.MissingTokenError as e:
+ # Log the failed login
+ log_failed_login(request)
+
+ return json_response({
+ "status": "missing_token",
+ "reason": str(e),
+ })
+ except auth.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)
@@ -126,7 +152,7 @@ def me():
return json_response({
"status": "invalid",
"reason": "Incorrect username or password",
- })
+ })
resp = {
"status": "ok",
diff --git a/management/templates/index.html b/management/templates/index.html
index 3088ef63..b0d86dd3 100644
--- a/management/templates/index.html
+++ b/management/templates/index.html
@@ -297,7 +297,7 @@ function ajax_with_indicator(options) {
}
var api_credentials = ["", ""];
-function api(url, method, data, callback, callback_error) {
+function api(url, method, data, callback, callback_error, headers) {
// from http://www.webtoolkit.info/javascript-base64.html
function base64encode(input) {
_keyStr = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=";
@@ -335,7 +335,7 @@ function api(url, method, data, callback, callback_error) {
method: method,
cache: false,
data: data,
-
+ headers: headers,
// the custom DNS api sends raw POST/PUT bodies --- prevent URL-encoding
processData: typeof data != "string",
mimeType: typeof data == "string" ? "text/plain; charset=ascii" : null,
diff --git a/management/templates/login.html b/management/templates/login.html
index b6e74df6..0322dd5f 100644
--- a/management/templates/login.html
+++ b/management/templates/login.html
@@ -1,4 +1,29 @@
-
{{hostname}}
+
+
+
{{hostname}}
{% if no_users_exist or no_admins_exist %}
@@ -20,10 +45,10 @@ sudo tools/mail.py user make-admin me@{{hostname}}
{% endif %}
-
Log in here for your Mail-in-a-Box control panel.
+
Log in here for your Mail-in-a-Box control panel.
-