add user interface for managing 2fa

* update user schema with 2fa columns
This commit is contained in:
Felix Spöttel 2020-09-02 16:48:23 +02:00
parent 0d72566c99
commit a7a66929aa
7 changed files with 370 additions and 7 deletions

View File

@ -1,14 +1,15 @@
import os, os.path, re, json, time import os, os.path, re, json, time
import subprocess import multiprocessing.pool, subprocess
from functools import wraps 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, 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_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_two_factor_info, set_two_factor_secret, remove_two_factor_secret
env = utils.load_environment() env = utils.load_environment()
@ -83,8 +84,8 @@ def authorized_personnel_only(viewfunc):
def unauthorized(error): def unauthorized(error):
return auth_service.make_unauthorized_response() return auth_service.make_unauthorized_response()
def json_response(data): def json_response(data, status=200):
return Response(json.dumps(data, indent=2, sort_keys=True)+'\n', status=200, mimetype='application/json') 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? # 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) 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? # What's the current status of TLS certificates on all of the domain?
domains_status = get_web_domains_info(env) domains_status = get_web_domains_info(env)
domains_status = [ domains_status = [
@ -383,6 +384,50 @@ def ssl_provision_certs():
requests = provision_certificates(env, limit_domains=None) requests = provision_certificates(env, limit_domains=None)
return json_response({ "requests": requests }) 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 # WEB

View File

@ -547,6 +547,41 @@ def get_required_aliases(env):
return aliases 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): def kick(env, mail_result=None):
results = [] results = []
@ -608,6 +643,11 @@ 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_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__": if __name__ == "__main__":
import sys import sys

View File

@ -93,6 +93,7 @@
<li class="dropdown-header">Advanced Pages</li> <li class="dropdown-header">Advanced Pages</li>
<li><a href="#custom_dns" onclick="return show_panel(this);">Custom DNS</a></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="#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> <li><a href="/admin/munin" target="_blank">Munin Monitoring</a></li>
</ul> </ul>
</li> </li>
@ -131,7 +132,11 @@
{% include "custom-dns.html" %} {% include "custom-dns.html" %}
</div> </div>
<div id="panel_login" class="admin_panel"> <div id="panel_two_factor_auth" class="admin_panel">
{% include "two-factor-auth.html" %}
</div>
<div id="panel_login" class="admin_panel">
{% include "login.html" %} {% include "login.html" %}
</div> </div>

View File

@ -0,0 +1,220 @@
<style>
.twofactor #setup-2fa,
.twofactor #disable-2fa,
.twofactor #output-2fa {
display: none;
}
.twofactor.loaded .loading-indicator {
display: none;
}
.twofactor.disabled #disable-2fa,
.twofactor.enabled #setup-2fa {
display: none;
}
.twofactor.disabled #setup-2fa,
.twofactor.enabled #disable-2fa {
display: block;
}
.twofactor #qr-2fa img {
display: block;
width: 256px;
max-width: 100%;
height: auto;
}
.twofactor #output-2fa.visible {
display: block;
}
</style>
<h2>Two Factor Authentication</h2>
<div class="twofactor">
<div class="loading-indicator">Loading...</div>
<form id="setup-2fa">
<div class="form-group">
<h3>Setup</h3>
<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>
<p>1. Scan the QR code or enter the secret into an authenticator app (e.g. Google Authenticator)</p>
<div id="qr-2fa"></div>
</div>
<div class="form-group">
<label for="otp">2. Enter the code displayed in the Authenticator app</label>
<input type="text" id="setup-otp" class="form-control" placeholder="6-digit code" />
</div>
<input type="hidden" id="setup-secret" />
<div class="form-group">
<button id="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.</p>
</div>
<button type="submit" class="btn btn-danger">Disable two factor authentication</button>
</form>
<div id="output-2fa" class="panel panel-danger">
<div class="panel-body"></div>
</div>
</div>
<script>
var el = {
disableForm: document.getElementById('disable-2fa'),
output: document.getElementById('output-2fa'),
setupForm: document.getElementById('setup-2fa'),
setupInputOtp: document.getElementById('setup-otp'),
setupInputSecret: document.getElementById('setup-secret'),
setupQr: document.getElementById('qr-2fa'),
setupSubmit: document.querySelector('#setup-2fa button[type="submit"]'),
wrapper: document.querySelector('.twofactor')
}
function update_setup_disabled(evt) {
var val = evt.target.value.trim();
if (
typeof val !== 'string' ||
typeof el.setupInputSecret.value !== 'string' ||
val.length !== 6 ||
el.setupInputSecret.value.length !== 32 ||
!(/^\+?\d+$/.test(val))
) {
el.setupSubmit.setAttribute('disabled', '');
} else {
el.setupSubmit.removeAttribute('disabled');
}
}
function render_setup(res) {
function render_qr_code(encoded) {
var img = document.createElement('img');
img.src = encoded;
var code = document.createElement('div');
code.innerHTML = `Secret: ${res.secret}`;
el.setupQr.appendChild(img);
el.setupQr.appendChild(code);
}
el.setupInputOtp.addEventListener('input', update_setup_disabled);
el.setupForm.addEventListener('submit', do_setup);
el.setupInputSecret.setAttribute('value', res.secret);
render_qr_code(res.qr_code);
el.wrapper.classList.add('disabled');
}
function render_disable() {
el.disableForm.addEventListener('submit', do_disable);
el.wrapper.classList.add('enabled');
}
function hide_error() {
el.output.querySelector('.panel-body').innerHTML = '';
el.output.classList.remove('visible');
}
function render_error(msg) {
el.output.querySelector('.panel-body').innerHTML = msg;
el.output.classList.add('visible');
}
function reset_view() {
el.wrapper.classList.remove('loaded', 'disabled', 'enabled');
el.disableForm.removeEventListener('submit', do_disable);
hide_error();
el.setupForm.reset();
el.setupForm.removeEventListener('submit', do_setup);
el.setupInputSecret.setAttribute('value', '');
el.setupInputOtp.removeEventListener('input', update_setup_disabled);
el.setupSubmit.setAttribute('disabled', '');
el.setupQr.innerHTML = '';
}
function show_two_factor_auth() {
reset_view();
api(
'/two-factor-auth/status',
'GET',
{},
function(res) {
el.wrapper.classList.add('loaded');
var isEnabled = res.status === 'on'
return isEnabled ? render_disable(res) : render_setup(res);
}
);
}
function do_disable(evt) {
evt.preventDefault();
hide_error();
api(
'/two-factor-auth/disable',
'POST',
{},
function() { show_two_factor_auth(); }
);
return false;
}
function do_setup(evt) {
evt.preventDefault();
hide_error();
api(
'/two-factor-auth/setup',
'POST',
{
token: $(el.setupInputOtp).val(),
secret: $(el.setupInputSecret).val()
},
function(res) { show_two_factor_auth(); },
function(res) {
var errorMessage = 'Something went wrong.';
var parsed;
try {
parsed = JSON.parse(res);
} catch (err) {
return render_error(errorMessage);
}
var error = parsed && parsed.error
? parsed.error
: null;
if (error === 'token_mismatch') {
errorMessage = 'Code does not match.';
} else if (error === 'bad_input') {
errorMessage = 'Received request with malformed data.';
}
render_error(errorMessage);
}
);
return false;
}
</script>

51
management/totp.py Normal file
View File

@ -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

View File

@ -20,7 +20,8 @@ db_path=$STORAGE_ROOT/mail/users.sqlite
# Create an empty database if it doesn't yet exist. # Create an empty database if it doesn't yet exist.
if [ ! -f $db_path ]; then 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; # 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; 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 fi

View File

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