add user interface for managing 2fa
* update user schema with 2fa columns
This commit is contained in:
parent
0d72566c99
commit
a7a66929aa
|
@ -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')
|
||||||
|
|
||||||
###################################
|
###################################
|
||||||
|
|
||||||
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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>
|
||||||
|
@ -129,6 +130,10 @@
|
||||||
|
|
||||||
<div id="panel_custom_dns" class="admin_panel">
|
<div id="panel_custom_dns" class="admin_panel">
|
||||||
{% include "custom-dns.html" %}
|
{% include "custom-dns.html" %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="panel_two_factor_auth" class="admin_panel">
|
||||||
|
{% include "two-factor-auth.html" %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="panel_login" class="admin_panel">
|
<div id="panel_login" class="admin_panel">
|
||||||
|
|
|
@ -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>
|
|
@ -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
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
Loading…
Reference in New Issue