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.

-
-
+ +
+ +
+ +
+
@@ -53,7 +84,6 @@ sudo tools/mail.py user make-admin me@{{hostname}}
-