diff --git a/README.md b/README.md index 3162599..5ff39d2 100644 --- a/README.md +++ b/README.md @@ -158,6 +158,10 @@ ckanext.security.brute_force_key = user_name # Detect brute force attempts by u # You can disable the fix in this plugin by: ckanext.security.disable_password_reset_override = true +# Two factor authentication is enabled for all users by default +# optional configuration to disable 2fa +ckanext.security.enable_totp = true # set to false to disable 2fa + # Provide a help page to allow 2fa users to contact support or get more information # Shows up as 'Need help?' on the 2fa entry form beside the submit button. Does not display a link if none provided ckanext.security.mfa_help_link = https://data.govt.nz/catalogue-guide/releasing-data-on-data-govt-nz/how-do-i-set-up-two-factor-authentication/ diff --git a/ckanext/security/authenticator.py b/ckanext/security/authenticator.py index 84137d7..7921d32 100644 --- a/ckanext/security/authenticator.py +++ b/ckanext/security/authenticator.py @@ -6,12 +6,14 @@ import pylons from ckan.lib.cli import MockTranslator +from ckan import model from ckan.lib.authenticator import UsernamePasswordAuthenticator from ckan.model import User -from ckan.common import config from webob.request import Request import ckan.plugins as p +from ckan.plugins.toolkit import config from ckanext.security.cache.login import LoginThrottle +from ckanext.security.helpers import security_enable_totp from ckanext.security.model import SecurityTOTP, ReplayAttackException log = logging.getLogger(__name__) @@ -73,6 +75,10 @@ def authenticate(self, environ, identity): to log into a specific account within a period of time.""" try: user_name = identity['login'] + user = model.User.by_name(user_name) + if not user: + user = model.User.by_email(user_name) + user_name = user.name except KeyError: return None @@ -105,6 +111,13 @@ def authenticate(self, environ, identity): # Increment the throttle counter if the login failed. throttle.increment() + # totp authentication is enabled by default for all users + # totp can be disabled, if needed, by setting + # ckanext.security.enable_totp to false in configurations + if not security_enable_totp(): + throttle.reset() + return auth_user_name + # if the CKAN authenticator has successfully authenticated # the request and the user wasn't locked out above, # then check the TOTP parameter to see if it is valid diff --git a/ckanext/security/helpers.py b/ckanext/security/helpers.py new file mode 100644 index 0000000..af54a54 --- /dev/null +++ b/ckanext/security/helpers.py @@ -0,0 +1,5 @@ +from ckan.plugins.toolkit import asbool, config + + +def security_enable_totp(): + return asbool(config.get('ckanext.security.enable_totp', True)) diff --git a/ckanext/security/plugin/__init__.py b/ckanext/security/plugin/__init__.py index fc0f311..e52f186 100644 --- a/ckanext/security/plugin/__init__.py +++ b/ckanext/security/plugin/__init__.py @@ -9,6 +9,8 @@ validate_upload_type, validate_upload_presence ) from ckanext.security.logic import auth, action +from ckanext.security.helpers import security_enable_totp +from ckanext.security import validators try: tk.requires_ckan_version("2.9") @@ -26,6 +28,7 @@ class CkanSecurityPlugin(MixinPlugin, p.SingletonPlugin): p.implements(p.IActions) p.implements(p.IAuthFunctions) p.implements(p.ITemplateHelpers) + p.implements(p.IValidators) # BEGIN Hooks for IConfigurer @@ -50,6 +53,14 @@ def update_config(self, config): # END Hooks for IConfigurer + # BEGIN hooks for IValidators + def get_validators(self): + return { + 'user_password_validator': validators.user_password_validator, + 'old_username_validator': validators.old_username_validator, + } + # END hooks for IValidators + # BEGIN Hooks for IResourceController def before_create(self, context, resource): @@ -105,4 +116,5 @@ def get_auth_functions(self): def get_helpers(self): return { 'check_ckan_version': tk.check_ckan_version, + 'security_enable_totp': security_enable_totp, } diff --git a/ckanext/security/templates/user/edit_user_form.html b/ckanext/security/templates/user/edit_user_form.html index 1bd5954..5674618 100644 --- a/ckanext/security/templates/user/edit_user_form.html +++ b/ckanext/security/templates/user/edit_user_form.html @@ -47,14 +47,16 @@ {{ form.input('password2', type='password', label=_('Confirm Password'), id='field-password-confirm', value=data.password2, error=errors.password2, classes=['control-medium'], attrs={'autocomplete': 'off'}) }} -
- {{_('Two factor authentication')}} - {% if h.check_ckan_version('2.9') %} - {% link_for _('Manage two factor authentication'), controller='mfa_user', action='configure_mfa', id=data.id, class_='btn btn-default pull-left', icon='cog' %} - {% else %} - {% link_for _('Manage two factor authentication'), controller='ckanext.security.controllers:MFAUserController', action='configure_mfa', id=data.id, class_='btn btn-default pull-left', icon='cog' %} - {% endif %} -
+ {% if h.security_enable_totp() %} +
+ {{_('Two factor authentication')}} + {% if h.check_ckan_version('2.9') %} + {% link_for _('Manage two factor authentication'), controller='mfa_user', action='configure_mfa', id=data.id, class_='btn btn-default pull-left', icon='cog' %} + {% else %} + {% link_for _('Manage two factor authentication'), controller='ckanext.security.controllers:MFAUserController', action='configure_mfa', id=data.id, class_='btn btn-default pull-left', icon='cog' %} + {% endif %} +
+ {% endif %}
{% block delete_button %} diff --git a/ckanext/security/templates/user/snippets/login_form.html b/ckanext/security/templates/user/snippets/login_form.html index 6268c72..24e88e8 100644 --- a/ckanext/security/templates/user/snippets/login_form.html +++ b/ckanext/security/templates/user/snippets/login_form.html @@ -8,11 +8,13 @@ {% set username_error = true if error_summary %} {% set password_error = true if error_summary %} -{% if h.check_ckan_version('2.9') %} - {% asset 'security/mfa_login' %} -{% else %} - {% resource 'security/login_ajax.js' %} - {% resource 'security/qrious.js' %} +{% if h.security_enable_totp() %} + {% if h.check_ckan_version('2.9') %} + {% asset 'security/mfa_login' %} + {% else %} + {% resource 'security/login_ajax.js' %} + {% resource 'security/qrious.js' %} + {% endif %} {% endif %} @@ -29,7 +31,7 @@
- {{ form.input('login', label=_("Username"), id='field-login', value="", error=username_error, classes=["control-medium"]) }} + {{ form.input('login', label=_("Username or Email"), id='field-login', value="", error=username_error, classes=["control-medium"]) }} {{ form.input('password', label=_("Password"), id='field-password', type="password", value="", error=password_error, classes=["control-medium"]) }}
@@ -43,27 +45,29 @@
- + {% endif %} diff --git a/ckanext/security/utils.py b/ckanext/security/utils.py index bd19991..3da7385 100644 --- a/ckanext/security/utils.py +++ b/ckanext/security/utils.py @@ -123,6 +123,8 @@ def login(): user_name = identity['login'] user = model.User.by_name(user_name) + if not user: + user = model.User.by_email(user_name) login_throttle_key = get_login_throttle_key(request, user_name) if login_throttle_key is None: @@ -174,7 +176,6 @@ def login(): log.info('User %s supplied invalid 2fa code', user_name) response_status = 403 throttle.increment() - return (response_status, json.dumps(res)) except Exception as err: diff --git a/ckanext/security/validators.py b/ckanext/security/validators.py index 147f056..0a49ade 100644 --- a/ckanext/security/validators.py +++ b/ckanext/security/validators.py @@ -1,6 +1,7 @@ # encoding: utf-8 import six import string +import collections from ckan import authz from ckan.common import _ @@ -10,9 +11,24 @@ MIN_LEN_ERROR = ( 'Your password must be {} characters or longer, and consist of at least ' 'three of the following character sets: uppercase characters, lowercase ' - 'characters, digits, punctuation & special characters.' + 'characters, digits, punctuation & special characters, and not contain ' + 'too many repeated characters.' ) +def _too_many_repeated_characters(value): + """ does the password contain too many repeated characters + + Returns True if the most frequent character is >= 1/3 of the characters. + e.g. "password" is false: ct(s)==2 < 8/3 + "aaaaword" is true: ct(a)==4 > 8/3 + + :param s: proposed password + :returns: boolean, True if password is ok by this criteria + """ + char_counts = collections.Counter(value) + # note, will fail on empty password, but caller checks MIN_PASSWORD_LENGTH + return (char_counts.most_common(1)[0][1] >= (len(value)/3)) + def user_password_validator(key, data, errors, context): value = data[key] @@ -31,7 +47,8 @@ def user_password_validator(key, data, errors, context): any(x.isdigit() for x in value), any(x in string.punctuation for x in value) ] - if len(value) < MIN_PASSWORD_LENGTH or sum(rules) < 3: + if len(value) < MIN_PASSWORD_LENGTH or sum(rules) < 3 \ + or _too_many_repeated_characters(value): raise Invalid(_(MIN_LEN_ERROR.format(MIN_PASSWORD_LENGTH)))