1
0
mirror of https://github.com/mail-in-a-box/mailinabox.git synced 2024-11-24 02:37:05 +00:00

implement two factor check during login

This commit is contained in:
Felix Spöttel 2020-09-02 17:23:32 +02:00
parent a7a66929aa
commit 3c3683429b
4 changed files with 130 additions and 26 deletions

View File

@ -2,12 +2,19 @@ import base64, os, os.path, hmac
from flask import make_response from flask import make_response
import utils import utils, totp
from mailconfig import get_mail_password, get_mail_user_privileges 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_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'
class MissingTokenError(ValueError):
pass
class BadTokenError(ValueError):
pass
class KeyAuthService: class KeyAuthService:
"""Generate an API key for authenticating clients """Generate an API key for authenticating clients
@ -76,8 +83,35 @@ class KeyAuthService:
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 user-specific
# API key or password. Raises or returns privs. # API key or password. Raises or returns privs and an indicator
return (username, self.get_user_credentials(username, password, env)) # 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): def get_user_credentials(self, email, pw, env):
# Validate a user's credentials. On success returns a list of # Validate a user's credentials. On success returns a list of
@ -88,11 +122,13 @@ class KeyAuthService:
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.
pass is_user_key = True
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.
@ -119,7 +155,7 @@ 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 return (privs, is_user_key)
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 # Store an HMAC with the client. The hashed message of the HMAC will be the user's

View File

@ -40,14 +40,23 @@ def authorized_personnel_only(viewfunc):
error = None error = None
try: try:
email, privs = auth_service.authenticate(request, env) 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: except ValueError as e:
# Write a line in the log recording the failed login
log_failed_login(request)
# Authentication failed. # Authentication failed.
privs = [] privs = []
error = "Incorrect username or password" 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? # Authorized to access an API view?
if "admin" in privs: if "admin" in privs:
# Call view func. # Call view func.
@ -119,6 +128,23 @@ 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 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: except ValueError as e:
# Log the failed login # Log the failed login
log_failed_login(request) log_failed_login(request)

View File

@ -297,7 +297,7 @@ function ajax_with_indicator(options) {
} }
var api_credentials = ["", ""]; 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 // from http://www.webtoolkit.info/javascript-base64.html
function base64encode(input) { function base64encode(input) {
_keyStr = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/="; _keyStr = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=";
@ -335,7 +335,7 @@ function api(url, method, data, callback, callback_error) {
method: method, method: method,
cache: false, cache: false,
data: data, data: data,
headers: headers,
// the custom DNS api sends raw POST/PUT bodies --- prevent URL-encoding // the custom DNS api sends raw POST/PUT bodies --- prevent URL-encoding
processData: typeof data != "string", processData: typeof data != "string",
mimeType: typeof data == "string" ? "text/plain; charset=ascii" : null, mimeType: typeof data == "string" ? "text/plain; charset=ascii" : null,

View File

@ -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.twofactor #loginOtp {
display: block
}
</style>
<h1 class="title">{{hostname}}</h1>
{% if no_users_exist or no_admins_exist %} {% if no_users_exist or no_admins_exist %}
<div class="row"> <div class="row">
@ -20,10 +45,10 @@ sudo tools/mail.py user make-admin me@{{hostname}}</pre>
</div> </div>
{% endif %} {% 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;"> <div class="login">
<form class="form-horizontal" role="form" onsubmit="do_login(); return false;" method="get"> <form id="loginForm" class="form-horizontal" role="form" onsubmit="do_login(); return false;" method="get">
<div class="form-group"> <div class="form-group">
<label for="inputEmail3" class="col-sm-3 control-label">Email</label> <label for="inputEmail3" class="col-sm-3 control-label">Email</label>
<div class="col-sm-9"> <div class="col-sm-9">
@ -45,6 +70,12 @@ sudo tools/mail.py user make-admin me@{{hostname}}</pre>
</div> </div>
</div> </div>
</div> </div>
<div class="form-group" id="loginOtp">
<label for="loginOtpInput" class="col-sm-3 control-label">Two Factor Code</label>
<div class="col-sm-9">
<input type="text" class="form-control" id="loginOtpInput" placeholder="123456">
</div>
</div>
<div class="form-group"> <div class="form-group">
<div class="col-sm-offset-3 col-sm-9"> <div class="col-sm-offset-3 col-sm-9">
<button type="submit" class="btn btn-default">Sign in</button> <button type="submit" class="btn btn-default">Sign in</button>
@ -53,7 +84,6 @@ sudo tools/mail.py user make-admin me@{{hostname}}</pre>
</form> </form>
</div> </div>
<script> <script>
function do_login() { function do_login() {
if ($('#loginEmail').val() == "") { if ($('#loginEmail').val() == "") {
@ -79,13 +109,17 @@ function do_login() {
function(response) { function(response) {
// This API call always succeeds. It returns a JSON object indicating // This API call always succeeds. It returns a JSON object indicating
// whether the request was authenticated or not. // whether the request was authenticated or not.
if (response.status != "ok") { if (response.status != 'ok') {
if (response.status === 'missing_token' && !$('#loginForm').hasClass('twofactor')) {
$('#loginForm').addClass('twofactor');
} else {
$('#loginForm').removeClass('twofactor');
// Show why the login failed. // Show why the login failed.
show_modal_error("Login Failed", response.reason) show_modal_error("Login Failed", response.reason)
// Reset any saved credentials. // Reset any saved credentials.
do_logout(); do_logout();
}
} else if (!("api_key" in response)) { } else if (!("api_key" in response)) {
// Login succeeded but user might not be authorized! // Login succeeded but user might not be authorized!
show_modal_error("Login Failed", "You are not an administrator on this system.") show_modal_error("Login Failed", "You are not an administrator on this system.")
@ -102,6 +136,8 @@ function do_login() {
// Try to wipe the username/password information. // Try to wipe the username/password information.
$('#loginEmail').val(''); $('#loginEmail').val('');
$('#loginPassword').val(''); $('#loginPassword').val('');
$('#loginOtpInput').val('');
$('#loginForm').removeClass('twofactor');
// Remember the credentials. // Remember the credentials.
if (typeof localStorage != 'undefined' && typeof sessionStorage != 'undefined') { if (typeof localStorage != 'undefined' && typeof sessionStorage != 'undefined') {
@ -119,7 +155,11 @@ function do_login() {
// which confuses the loading indicator. // 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); setTimeout(function() { show_panel(!switch_back_to_panel || switch_back_to_panel == "login" ? 'system_status' : switch_back_to_panel) }, 300);
} }
}) },
undefined,
{
'x-auth-token': $('#loginOtpInput').val()
});
} }
function do_logout() { function do_logout() {
@ -132,6 +172,8 @@ function do_logout() {
} }
function show_login() { function show_login() {
$('#loginForm').removeClass('twofactor');
$('#loginOtpInput').val('');
$('#loginEmail,#loginPassword').each(function() { $('#loginEmail,#loginPassword').each(function() {
var input = $(this); var input = $(this);
if (!$.trim(input.val())) { if (!$.trim(input.val())) {