Projet

Général

Profil

0001-add-new-API-to-validate-passwords-fixes-24833.patch

Benjamin Dauvergne, 06 juillet 2018 15:15

Télécharger (10 ko)

Voir les différences:

Subject: [PATCH 1/2] add new API to validate passwords (fixes #24833)

POST /api/validate-passwords/ 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(+)
src/authentic2/api_urls.py
13 13
                           api_views.role_memberships, name='a2-api-role-member'),
14 14
                       url(r'^check-password/$', api_views.check_password,
15 15
                           name='a2-api-check-password'),
16
                       url(r'^validate-password/$', api_views.validate_password,
17
                           name='a2-api-validate-password'),
16 18
]
17 19

  
18 20
urlpatterns += api_views.router.urls
src/authentic2/api_views.py
25 25

  
26 26
from django_filters.rest_framework import FilterSet
27 27

  
28
from .passwords import get_password_checker
28 29
from .custom_user.models import User
29 30
from . import utils, decorators, attribute_kinds, app_settings, hooks
30 31
from .models import Attribute, PasswordReset
......
709 710

  
710 711

  
711 712
check_password = CheckPasswordAPI.as_view()
713

  
714

  
715
class ValidatePasswordSerializer(serializers.Serializer):
716
    password = serializers.CharField(required=True)
717

  
718

  
719
class ValidatePasswordAPI(BaseRpcView):
720
    permission_classes = ()
721
    serializer_class = ValidatePasswordSerializer
722

  
723
    def rpc(self, request, serializer):
724
        password_checker = get_password_checker()
725
        checks = []
726
        result = {'result': 1, 'checks': checks}
727
        ok = True
728
        for check in password_checker(serializer.validated_data['password']):
729
            ok = ok and check.result
730
            checks.append({
731
                'result': check.result,
732
                'label': check.label,
733
            })
734
        result['ok'] = ok
735
        return result, status.HTTP_200_OK
736

  
737

  
738
validate_password = ValidatePasswordAPI.as_view()
src/authentic2/app_settings.py
143 143
    A2_PASSWORD_POLICY_MIN_LENGTH=Setting(default=8, definition='Minimum number of characters in a password'),
144 144
    A2_PASSWORD_POLICY_REGEX=Setting(default=None, definition='Regular expression for validating passwords'),
145 145
    A2_PASSWORD_POLICY_REGEX_ERROR_MSG=Setting(default=None, definition='Error message to show when the password do not validate the regular expression'),
146
    A2_PASSWORD_POLICY_CLASS=Setting(
147
        default='authentic2.passwords.DefaultPasswordChecker',
148
        definition='path of a class to validate passwords'),
146 149
    A2_AUTH_PASSWORD_ENABLE=Setting(default=True, definition='Activate login/password authentication', names=('AUTH_PASSWORD',)),
147 150
    A2_LOGIN_FAILURE_COUNT_BEFORE_WARNING=Setting(default=0,
148 151
            definition='Failure count before logging a warning to '
src/authentic2/passwords.py
1 1
import string
2 2
import random
3
import re
4
import abc
3 5

  
6
from django.utils.translation import ugettext as _
7
from django.utils.module_loading import import_string
4 8
from . import app_settings
5 9

  
10

  
6 11
def generate_password():
7 12
    '''Generate a password that validates current password policy.
8 13

  
......
22 27
            new_password.append(random.choice(cls))
23 28
    random.shuffle(new_password)
24 29
    return ''.join(new_password)
30

  
31

  
32
class PasswordChecker(object):
33
    __metaclass__ = abc.ABCMeta
34

  
35
    class Check(object):
36
        def __init__(self, label, result):
37
            self.label = label
38
            self.result = result
39

  
40
    def __init__(self, *args, **kwargs):
41
        pass
42

  
43
    @abc.abstractmethod
44
    def __call__(self, password, **kwargs):
45
        '''Return an iterable of Check objects giving the list of checks and
46
           their result.'''
47
        return []
48

  
49

  
50
class DefaultPasswordChecker(PasswordChecker):
51
    @property
52
    def min_length(self):
53
        return app_settings.A2_PASSWORD_POLICY_MIN_LENGTH
54

  
55
    @property
56
    def at_least_one_lowercase(self):
57
        return app_settings.A2_PASSWORD_POLICY_MIN_CLASSES > 0
58

  
59
    @property
60
    def at_least_one_digit(self):
61
        return app_settings.A2_PASSWORD_POLICY_MIN_CLASSES > 1
62

  
63
    @property
64
    def at_least_one_uppercase(self):
65
        return app_settings.A2_PASSWORD_POLICY_MIN_CLASSES > 2
66

  
67
    @property
68
    def regexp(self):
69
        return app_settings.A2_PASSWORD_POLICY_REGEX
70

  
71
    @property
72
    def regexp_label(self):
73
        return app_settings.A2_PASSWORD_POLICY_REGEX_ERROR_MSG
74

  
75
    def __call__(self, password, **kwargs):
76
        if self.min_length:
77
            yield self.Check(
78
                result=len(password) >= self.min_length,
79
                label=_('at least %s characters') % self.min_length)
80

  
81
        if self.at_least_one_lowercase:
82
            yield self.Check(
83
                result=any(c.islower() for c in password),
84
                label=_('at least 1 lowercase letter'))
85

  
86
        if self.at_least_one_digit:
87
            yield self.Check(
88
                result=any(c.isdigit() for c in password),
89
                label=_('at least 1 digit'))
90

  
91
        if self.at_least_one_uppercase:
92
            yield self.Check(
93
                result=any(c.isupper() for c in password),
94
                label=_('at least 1 uppercase letter'))
95

  
96
        if self.regexp and self.regexp_label:
97
            yield self.Check(
98
                result=bool(re.match(self.regexp, password)),
99
                label=self.regexp_label)
100

  
101

  
102
def get_password_checker(*args, **kwargs):
103
    return import_string(app_settings.A2_PASSWORD_POLICY_CLASS)(*args, **kwargs)
tests/test_api.py
826 826
    app.authorization = ('Basic', (user.username, user.username))
827 827
    resp = app.get('/api/users/')
828 828
    assert 'A2_OPENED_SESSION' not in app.cookies
829

  
830

  
831
def test_validate_password_default(app):
832
    for password, ok, length, lower, digit, upper in (
833
            ('.',            False, False, False, False, False),
834
            ('x' * 8,        False,  True,  True, False, False),
835
            ('x' * 8 + '1',  False,  True,  True,  True, False),
836
            ('x' * 8 + '1X',  True,  True,  True,  True,  True)):
837
        response = app.post_json('/api/validate-password/', params={'password': password}) 
838
        assert response.json['result'] == 1
839
        assert response.json['ok'] is ok
840
        assert len(response.json['checks']) == 4
841
        assert response.json['checks'][0]['label'] == 'at least 8 characters'
842
        assert response.json['checks'][0]['result'] is length
843
        assert response.json['checks'][1]['label'] == 'at least 1 lowercase letter'
844
        assert response.json['checks'][1]['result'] is lower
845
        assert response.json['checks'][2]['label'] == 'at least 1 digit'
846
        assert response.json['checks'][2]['result'] is digit
847
        assert response.json['checks'][3]['label'] == 'at least 1 uppercase letter'
848
        assert response.json['checks'][3]['result'] is upper
849

  
850

  
851
def test_validate_password_regex(app, settings):
852
    settings.A2_PASSWORD_POLICY_REGEX = '^.*ok.*$'
853
    settings.A2_PASSWORD_POLICY_REGEX_ERROR_MSG = 'must contain "ok"'
854

  
855
    response = app.post_json('/api/validate-password/', params={'password': 'x' * 8 + '1X'})
856
    assert response.json['result'] == 1
857
    assert response.json['ok'] is False
858
    assert len(response.json['checks']) == 5
859
    assert response.json['checks'][0]['label'] == 'at least 8 characters'
860
    assert response.json['checks'][0]['result'] is True
861
    assert response.json['checks'][1]['label'] == 'at least 1 lowercase letter'
862
    assert response.json['checks'][1]['result'] is True
863
    assert response.json['checks'][2]['label'] == 'at least 1 digit'
864
    assert response.json['checks'][2]['result'] is True
865
    assert response.json['checks'][3]['label'] == 'at least 1 uppercase letter'
866
    assert response.json['checks'][3]['result'] is True
867
    assert response.json['checks'][4]['label'] == 'must contain "ok"'
868
    assert response.json['checks'][4]['result'] is False
869

  
870
    response = app.post_json('/api/validate-password/', params={'password': 'x' * 8 + 'ok1X'})
871
    assert response.json['result'] == 1
872
    assert response.json['ok'] is True
873
    assert len(response.json['checks']) == 5
874
    assert response.json['checks'][0]['label'] == 'at least 8 characters'
875
    assert response.json['checks'][0]['result'] is True
876
    assert response.json['checks'][1]['label'] == 'at least 1 lowercase letter'
877
    assert response.json['checks'][1]['result'] is True
878
    assert response.json['checks'][2]['label'] == 'at least 1 digit'
879
    assert response.json['checks'][2]['result'] is True
880
    assert response.json['checks'][3]['label'] == 'at least 1 uppercase letter'
881
    assert response.json['checks'][3]['result'] is True
882
    assert response.json['checks'][4]['label'] == 'must contain "ok"'
883
    assert response.json['checks'][4]['result'] is True
829
-