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.
This commit is contained in:
Benjamin Dauvergne 2018-07-06 12:04:47 +02:00
parent 103acb8dea
commit 35b3136ef4
5 changed files with 166 additions and 0 deletions

View File

@ -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

View File

@ -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()

View File

@ -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 '

View File

@ -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)

View File

@ -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