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:
parent
103acb8dea
commit
35b3136ef4
|
@ -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
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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 '
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in New Issue