From dfa53a7bc50818f52eef3952e2ff4b1e0c3a4aaf Mon Sep 17 00:00:00 2001 From: Benjamin Dauvergne Date: Fri, 6 Jul 2018 12:04:47 +0200 Subject: [PATCH 1/3] add new API to validate passwords (fixes #24833) POST /api/validate-password/ HTTP/1.1 Conten-Type: application/json {"password": "whatever"} 200 Ok Content-Type: application/json { "result": 1, "ok": false, "checks": [ {"label": "at least 1 digit", "result": false} ] } This API is public. --- src/authentic2/api_urls.py | 2 + src/authentic2/api_views.py | 27 ++++++++++++ src/authentic2/app_settings.py | 3 ++ src/authentic2/passwords.py | 79 ++++++++++++++++++++++++++++++++++ tests/test_api.py | 55 +++++++++++++++++++++++ 5 files changed, 166 insertions(+) diff --git a/src/authentic2/api_urls.py b/src/authentic2/api_urls.py index 6923b01a..a14aa2b5 100644 --- a/src/authentic2/api_urls.py +++ b/src/authentic2/api_urls.py @@ -13,6 +13,8 @@ urlpatterns = [ api_views.role_memberships, name='a2-api-role-member'), url(r'^check-password/$', api_views.check_password, name='a2-api-check-password'), + url(r'^validate-password/$', api_views.validate_password, + name='a2-api-validate-password'), ] urlpatterns += api_views.router.urls diff --git a/src/authentic2/api_views.py b/src/authentic2/api_views.py index 6e0dafd4..62d5b139 100644 --- a/src/authentic2/api_views.py +++ b/src/authentic2/api_views.py @@ -25,6 +25,7 @@ from rest_framework.decorators import list_route, detail_route from django_filters.rest_framework import FilterSet +from .passwords import get_password_checker from .custom_user.models import User from . import utils, decorators, attribute_kinds, app_settings, hooks from .models import Attribute, PasswordReset, Service @@ -717,3 +718,29 @@ class CheckPasswordAPI(BaseRpcView): check_password = CheckPasswordAPI.as_view() + + +class ValidatePasswordSerializer(serializers.Serializer): + password = serializers.CharField(required=True) + + +class ValidatePasswordAPI(BaseRpcView): + permission_classes = () + serializer_class = ValidatePasswordSerializer + + def rpc(self, request, serializer): + password_checker = get_password_checker() + checks = [] + result = {'result': 1, 'checks': checks} + ok = True + for check in password_checker(serializer.validated_data['password']): + ok = ok and check.result + checks.append({ + 'result': check.result, + 'label': check.label, + }) + result['ok'] = ok + return result, status.HTTP_200_OK + + +validate_password = ValidatePasswordAPI.as_view() diff --git a/src/authentic2/app_settings.py b/src/authentic2/app_settings.py index e1ab8f06..015ea499 100644 --- a/src/authentic2/app_settings.py +++ b/src/authentic2/app_settings.py @@ -143,6 +143,9 @@ default_settings = dict( A2_PASSWORD_POLICY_MIN_LENGTH=Setting(default=8, definition='Minimum number of characters in a password'), A2_PASSWORD_POLICY_REGEX=Setting(default=None, definition='Regular expression for validating passwords'), A2_PASSWORD_POLICY_REGEX_ERROR_MSG=Setting(default=None, definition='Error message to show when the password do not validate the regular expression'), + A2_PASSWORD_POLICY_CLASS=Setting( + default='authentic2.passwords.DefaultPasswordChecker', + definition='path of a class to validate passwords'), A2_AUTH_PASSWORD_ENABLE=Setting(default=True, definition='Activate login/password authentication', names=('AUTH_PASSWORD',)), A2_LOGIN_FAILURE_COUNT_BEFORE_WARNING=Setting(default=0, definition='Failure count before logging a warning to ' diff --git a/src/authentic2/passwords.py b/src/authentic2/passwords.py index 6f6c446e..01b0f449 100644 --- a/src/authentic2/passwords.py +++ b/src/authentic2/passwords.py @@ -1,8 +1,13 @@ import string import random +import re +import abc +from django.utils.translation import ugettext as _ +from django.utils.module_loading import import_string from . import app_settings + def generate_password(): '''Generate a password that validates current password policy. @@ -22,3 +27,77 @@ def generate_password(): new_password.append(random.choice(cls)) random.shuffle(new_password) return ''.join(new_password) + + +class PasswordChecker(object): + __metaclass__ = abc.ABCMeta + + class Check(object): + def __init__(self, label, result): + self.label = label + self.result = result + + def __init__(self, *args, **kwargs): + pass + + @abc.abstractmethod + def __call__(self, password, **kwargs): + '''Return an iterable of Check objects giving the list of checks and + their result.''' + return [] + + +class DefaultPasswordChecker(PasswordChecker): + @property + def min_length(self): + return app_settings.A2_PASSWORD_POLICY_MIN_LENGTH + + @property + def at_least_one_lowercase(self): + return app_settings.A2_PASSWORD_POLICY_MIN_CLASSES > 0 + + @property + def at_least_one_digit(self): + return app_settings.A2_PASSWORD_POLICY_MIN_CLASSES > 1 + + @property + def at_least_one_uppercase(self): + return app_settings.A2_PASSWORD_POLICY_MIN_CLASSES > 2 + + @property + def regexp(self): + return app_settings.A2_PASSWORD_POLICY_REGEX + + @property + def regexp_label(self): + return app_settings.A2_PASSWORD_POLICY_REGEX_ERROR_MSG + + def __call__(self, password, **kwargs): + if self.min_length: + yield self.Check( + result=len(password) >= self.min_length, + label=_('%s characters') % self.min_length) + + if self.at_least_one_lowercase: + yield self.Check( + result=any(c.islower() for c in password), + label=_('1 lowercase letter')) + + if self.at_least_one_digit: + yield self.Check( + result=any(c.isdigit() for c in password), + label=_('1 digit')) + + if self.at_least_one_uppercase: + yield self.Check( + result=any(c.isupper() for c in password), + label=_('1 uppercase letter')) + + if self.regexp and self.regexp_label: + yield self.Check( + result=bool(re.match(self.regexp, password)), + label=self.regexp_label) + + +def get_password_checker(*args, **kwargs): + return import_string(app_settings.A2_PASSWORD_POLICY_CLASS)(*args, **kwargs) diff --git a/tests/test_api.py b/tests/test_api.py index 43cb678d..b01639b8 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -859,3 +859,58 @@ def test_no_opened_session_cookie_on_api(app, user, settings): app.authorization = ('Basic', (user.username, user.username)) resp = app.get('/api/users/') assert 'A2_OPENED_SESSION' not in app.cookies + + +def test_validate_password_default(app): + for password, ok, length, lower, digit, upper in ( + ('.', False, False, False, False, False), + ('x' * 8, False, True, True, False, False), + ('x' * 8 + '1', False, True, True, True, False), + ('x' * 8 + '1X', True, True, True, True, True)): + response = app.post_json('/api/validate-password/', params={'password': password}) + assert response.json['result'] == 1 + assert response.json['ok'] is ok + assert len(response.json['checks']) == 4 + assert response.json['checks'][0]['label'] == '8 characters' + assert response.json['checks'][0]['result'] is length + assert response.json['checks'][1]['label'] == '1 lowercase letter' + assert response.json['checks'][1]['result'] is lower + assert response.json['checks'][2]['label'] == '1 digit' + assert response.json['checks'][2]['result'] is digit + assert response.json['checks'][3]['label'] == '1 uppercase letter' + assert response.json['checks'][3]['result'] is upper + + +def test_validate_password_regex(app, settings): + settings.A2_PASSWORD_POLICY_REGEX = '^.*ok.*$' + settings.A2_PASSWORD_POLICY_REGEX_ERROR_MSG = 'must contain "ok"' + + response = app.post_json('/api/validate-password/', params={'password': 'x' * 8 + '1X'}) + assert response.json['result'] == 1 + assert response.json['ok'] is False + assert len(response.json['checks']) == 5 + assert response.json['checks'][0]['label'] == '8 characters' + assert response.json['checks'][0]['result'] is True + assert response.json['checks'][1]['label'] == '1 lowercase letter' + assert response.json['checks'][1]['result'] is True + assert response.json['checks'][2]['label'] == '1 digit' + assert response.json['checks'][2]['result'] is True + assert response.json['checks'][3]['label'] == '1 uppercase letter' + assert response.json['checks'][3]['result'] is True + assert response.json['checks'][4]['label'] == 'must contain "ok"' + assert response.json['checks'][4]['result'] is False + + response = app.post_json('/api/validate-password/', params={'password': 'x' * 8 + 'ok1X'}) + assert response.json['result'] == 1 + assert response.json['ok'] is True + assert len(response.json['checks']) == 5 + assert response.json['checks'][0]['label'] == '8 characters' + assert response.json['checks'][0]['result'] is True + assert response.json['checks'][1]['label'] == '1 lowercase letter' + assert response.json['checks'][1]['result'] is True + assert response.json['checks'][2]['label'] == '1 digit' + assert response.json['checks'][2]['result'] is True + assert response.json['checks'][3]['label'] == '1 uppercase letter' + assert response.json['checks'][3]['result'] is True + assert response.json['checks'][4]['label'] == 'must contain "ok"' + assert response.json['checks'][4]['result'] is True -- 2.18.0