mirror of
https://github.com/mail-in-a-box/mailinabox.git
synced 2026-03-04 15:54:48 +01:00
Add TOTP two-factor authentication to admin panel login (#1814)
* add user interface for managing 2fa * update user schema with 2fa columns * implement two factor check during login * Use pyotp for validating TOTP codes * also implements resynchronisation support via `pyotp`'s `valid_window option * Update API route naming, update setup page * Rename /two-factor-auth/ => /2fa/ * Nest totp routes under /2fa/totp/ * Update ids and methods in panel to allow for different setup types * Autofocus otp input when logging in, update layout * Extract TOTPStrategy class to totp.py * this decouples `TOTP` validation and storage logic from `auth` and moves it to `totp` * reduce `pyotp.validate#valid_window` from `2` to `1` * Update OpenApi docs, rename /2fa/ => /mfa/ * Decouple totp from users table by moving to totp_credentials table * this allows implementation of other mfa schemes in the future (webauthn) * also makes key management easier and enforces one totp credentials per user on db-level * Add sqlite migration * Rename internal validate_two_factor_secret => validate_two_factor_secret * conn.close() if mru_token update can't .commit() * Address review feedback, thanks @hija * Use hmac.compare_digest() to compare mru_token * Safeguard against empty mru_token column * hmac.compare_digest() expects arguments of type string, make sure we don't pass None * Currently, this cannot happen but we might not want to store `mru_token` during setup * Do not log failed login attempts for MissingToken errors * Due to the way that the /login UI works, this persists at least one failed login each time a user logs into the admin panel. This in turn triggers fail2ban at some point. * Add TOTP secret to user_key hash thanks @downtownallday * this invalidates all user_keys after TOTP status is changed for user * after changing TOTP state, a login is required * due to the forced login, we can't and don't need to store the code used for setup in `mru_code` * Typo * Reorganize the MFA backend methods * Reorganize MFA front-end and add label column * Fix handling of bad input when enabling mfa * Update openAPI docs * Remove unique key constraint on foreign key user_id in mfa table * Don't expose mru_token and secret for enabled mfas over HTTP * Only update mru_token for matched mfa row * Exclude mru_token in user key hash * Rename tools/mail.py to management/cli.py * Add MFA list/disable to the management CLI so admins can restore access if MFA device is lost Co-authored-by: Joshua Tauberer <jt@occams.info>
This commit is contained in:
@@ -97,11 +97,14 @@
|
||||
</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>
|
||||
@@ -131,6 +134,10 @@
|
||||
{% include "custom-dns.html" %}
|
||||
</div>
|
||||
|
||||
<div id="panel_mfa" class="admin_panel">
|
||||
{% include "mfa.html" %}
|
||||
</div>
|
||||
|
||||
<div id="panel_login" class="admin_panel">
|
||||
{% include "login.html" %}
|
||||
</div>
|
||||
@@ -292,7 +299,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+/=";
|
||||
@@ -330,7 +337,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,
|
||||
@@ -358,6 +365,16 @@ function api(url, method, data, callback, callback_error) {
|
||||
|
||||
var current_panel = null;
|
||||
var switch_back_to_panel = null;
|
||||
|
||||
function do_logout() {
|
||||
api_credentials = ["", ""];
|
||||
if (typeof localStorage != 'undefined')
|
||||
localStorage.removeItem("miab-cp-credentials");
|
||||
if (typeof sessionStorage != 'undefined')
|
||||
sessionStorage.removeItem("miab-cp-credentials");
|
||||
show_panel('login');
|
||||
}
|
||||
|
||||
function show_panel(panelid) {
|
||||
if (panelid.getAttribute)
|
||||
// we might be passed an HTMLElement <a>.
|
||||
|
||||
@@ -1,4 +1,29 @@
|
||||
<h1 style="margin: 1em; text-align: center">{{hostname}}</h1>
|
||||
<style>
|
||||
.title {
|
||||
margin: 1em;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
margin: 2em;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.login {
|
||||
margin: 0 auto;
|
||||
max-width: 32em;
|
||||
}
|
||||
|
||||
.login #loginOtp {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#loginForm.is-twofactor #loginOtp {
|
||||
display: block
|
||||
}
|
||||
</style>
|
||||
|
||||
<h1 class="title">{{hostname}}</h1>
|
||||
|
||||
{% if no_users_exist or no_admins_exist %}
|
||||
<div class="row">
|
||||
@@ -7,23 +32,23 @@
|
||||
<p class="text-danger">There are no users on this system! To make an administrative user,
|
||||
log into this machine using SSH (like when you first set it up) and run:</p>
|
||||
<pre>cd mailinabox
|
||||
sudo tools/mail.py user add me@{{hostname}}
|
||||
sudo tools/mail.py user make-admin me@{{hostname}}</pre>
|
||||
sudo management/cli.py user add me@{{hostname}}
|
||||
sudo management/cli.py user make-admin me@{{hostname}}</pre>
|
||||
{% else %}
|
||||
<p class="text-danger">There are no administrative users on this system! To make an administrative user,
|
||||
log into this machine using SSH (like when you first set it up) and run:</p>
|
||||
<pre>cd mailinabox
|
||||
sudo tools/mail.py user make-admin me@{{hostname}}</pre>
|
||||
sudo management/cli.py user make-admin me@{{hostname}}</pre>
|
||||
{% endif %}
|
||||
<hr>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<p style="margin: 2em; text-align: center;">Log in here for your Mail-in-a-Box control panel.</p>
|
||||
<p class="subtitle">Log in here for your Mail-in-a-Box control panel.</p>
|
||||
|
||||
<div style="margin: 0 auto; max-width: 32em;">
|
||||
<form class="form-horizontal" role="form" onsubmit="do_login(); return false;" method="get">
|
||||
<div class="login">
|
||||
<form id="loginForm" class="form-horizontal" role="form" onsubmit="do_login(); return false;" method="get">
|
||||
<div class="form-group">
|
||||
<label for="inputEmail3" class="col-sm-3 control-label">Email</label>
|
||||
<div class="col-sm-9">
|
||||
@@ -36,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">
|
||||
@@ -53,15 +85,15 @@ sudo tools/mail.py user make-admin me@{{hostname}}</pre>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
|
||||
<script>
|
||||
function do_login() {
|
||||
if ($('#loginEmail').val() == "") {
|
||||
show_modal_error("Login Failed", "Enter your email address.", function() {
|
||||
$('#loginEmail').focus();
|
||||
$('#loginEmail').focus();
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($('#loginPassword').val() == "") {
|
||||
show_modal_error("Login Failed", "Enter your email password.", function() {
|
||||
$('#loginPassword').focus();
|
||||
@@ -75,17 +107,29 @@ function do_login() {
|
||||
api(
|
||||
"/me",
|
||||
"GET",
|
||||
{ },
|
||||
function(response){
|
||||
{},
|
||||
function(response) {
|
||||
// This API call always succeeds. It returns a JSON object indicating
|
||||
// whether the request was authenticated or not.
|
||||
if (response.status != "ok") {
|
||||
// Show why the login failed.
|
||||
show_modal_error("Login Failed", response.reason)
|
||||
if (response.status != 'ok') {
|
||||
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');
|
||||
|
||||
// Reset any saved credentials.
|
||||
do_logout();
|
||||
// Show why the login failed.
|
||||
show_modal_error("Login Failed", response.reason)
|
||||
|
||||
// Reset any saved credentials.
|
||||
do_logout();
|
||||
}
|
||||
} else if (!("api_key" in response)) {
|
||||
// Login succeeded but user might not be authorized!
|
||||
show_modal_error("Login Failed", "You are not an administrator on this system.")
|
||||
@@ -102,6 +146,8 @@ function do_login() {
|
||||
// Try to wipe the username/password information.
|
||||
$('#loginEmail').val('');
|
||||
$('#loginPassword').val('');
|
||||
$('#loginOtpInput').val('');
|
||||
$('#loginForm').removeClass('is-twofactor');
|
||||
|
||||
// Remember the credentials.
|
||||
if (typeof localStorage != 'undefined' && typeof sessionStorage != 'undefined') {
|
||||
@@ -119,19 +165,16 @@ function do_login() {
|
||||
// which confuses the loading indicator.
|
||||
setTimeout(function() { show_panel(!switch_back_to_panel || switch_back_to_panel == "login" ? 'system_status' : switch_back_to_panel) }, 300);
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function do_logout() {
|
||||
api_credentials = ["", ""];
|
||||
if (typeof localStorage != 'undefined')
|
||||
localStorage.removeItem("miab-cp-credentials");
|
||||
if (typeof sessionStorage != 'undefined')
|
||||
sessionStorage.removeItem("miab-cp-credentials");
|
||||
show_panel('login');
|
||||
},
|
||||
undefined,
|
||||
{
|
||||
'x-auth-token': $('#loginOtpInput').val()
|
||||
});
|
||||
}
|
||||
|
||||
function show_login() {
|
||||
$('#loginForm').removeClass('is-twofactor');
|
||||
$('#loginOtpInput').val('');
|
||||
$('#loginEmail,#loginPassword').each(function() {
|
||||
var input = $(this);
|
||||
if (!$.trim(input.val())) {
|
||||
|
||||
242
management/templates/mfa.html
Normal file
242
management/templates/mfa.html
Normal file
@@ -0,0 +1,242 @@
|
||||
<style>
|
||||
.twofactor #totp-setup,
|
||||
.twofactor #disable-2fa,
|
||||
.twofactor #output-2fa {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.twofactor.loaded .loading-indicator {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.twofactor.disabled #disable-2fa,
|
||||
.twofactor.enabled #totp-setup {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.twofactor.disabled #totp-setup,
|
||||
.twofactor.enabled #disable-2fa {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.twofactor #totp-setup-qr img {
|
||||
display: block;
|
||||
width: 256px;
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.twofactor #output-2fa.visible {
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
|
||||
<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">
|
||||
<h3>Setup Instructions</h3>
|
||||
|
||||
<div class="form-group">
|
||||
<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-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">
|
||||
<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<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>
|
||||
</div>
|
||||
</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'),
|
||||
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')
|
||||
}
|
||||
|
||||
function update_setup_disabled(evt) {
|
||||
var val = evt.target.value.trim();
|
||||
|
||||
if (
|
||||
typeof val !== 'string' ||
|
||||
typeof el.totpSetupSecret.value !== 'string' ||
|
||||
val.length !== 6 ||
|
||||
el.totpSetupSecret.value.length !== 32 ||
|
||||
!(/^\+?\d+$/.test(val))
|
||||
) {
|
||||
el.totpSetupSubmit.setAttribute('disabled', '');
|
||||
} else {
|
||||
el.totpSetupSubmit.removeAttribute('disabled');
|
||||
}
|
||||
}
|
||||
|
||||
function render_totp_setup(provisioned_totp) {
|
||||
var img = document.createElement('img');
|
||||
img.src = "data:image/png;base64," + provisioned_totp.qr_code_base64;
|
||||
|
||||
var code = document.createElement('div');
|
||||
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', provisioned_totp.secret);
|
||||
|
||||
el.wrapper.classList.add('disabled');
|
||||
}
|
||||
|
||||
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() {
|
||||
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.totpSetupForm.reset();
|
||||
el.totpSetupForm.removeEventListener('submit', do_enable_totp);
|
||||
|
||||
el.totpSetupSecret.setAttribute('value', '');
|
||||
el.totpSetupToken.removeEventListener('input', update_setup_disabled);
|
||||
|
||||
el.totpSetupSubmit.setAttribute('disabled', '');
|
||||
el.totpQr.innerHTML = '';
|
||||
}
|
||||
|
||||
function show_mfa() {
|
||||
reset_view();
|
||||
|
||||
api(
|
||||
'/mfa/status',
|
||||
'POST',
|
||||
{},
|
||||
function(res) {
|
||||
el.wrapper.classList.add('loaded');
|
||||
|
||||
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);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function do_disable(evt) {
|
||||
evt.preventDefault();
|
||||
hide_error();
|
||||
|
||||
api(
|
||||
'/mfa/disable',
|
||||
'POST',
|
||||
{ type: 'totp' },
|
||||
function() {
|
||||
do_logout();
|
||||
}
|
||||
);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function do_enable_totp(evt) {
|
||||
evt.preventDefault();
|
||||
hide_error();
|
||||
|
||||
api(
|
||||
'/mfa/totp/enable',
|
||||
'POST',
|
||||
{
|
||||
token: $(el.totpSetupToken).val(),
|
||||
secret: $(el.totpSetupSecret).val(),
|
||||
label: $(el.totpSetupLabel).val()
|
||||
},
|
||||
function(res) { do_logout(); },
|
||||
function(res) { render_error(res); }
|
||||
);
|
||||
|
||||
return false;
|
||||
}
|
||||
</script>
|
||||
Reference in New Issue
Block a user