Move RegistrationValidationView into user_authn.
This commit is contained in:
@@ -19,7 +19,6 @@ from .accounts.views import (
|
||||
UsernameReplacementView
|
||||
)
|
||||
from .preferences.views import PreferencesDetailView, PreferencesView
|
||||
from .validation.views import RegistrationValidationView
|
||||
from .verification_api.views import IDVerificationStatusView
|
||||
|
||||
ME = AccountViewSet.as_view({
|
||||
@@ -158,11 +157,6 @@ urlpatterns = [
|
||||
UsernameReplacementView.as_view(),
|
||||
name='username_replacement'
|
||||
),
|
||||
url(
|
||||
r'^v1/validation/registration$',
|
||||
RegistrationValidationView.as_view(),
|
||||
name='registration_validation'
|
||||
),
|
||||
url(
|
||||
r'^v1/preferences/{}$'.format(settings.USERNAME_PATTERN),
|
||||
PreferencesView.as_view(),
|
||||
|
||||
@@ -1,233 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Tests for an API endpoint for client-side user data validation.
|
||||
"""
|
||||
|
||||
from __future__ import absolute_import
|
||||
|
||||
import unittest
|
||||
|
||||
import ddt
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import User
|
||||
from django.test.utils import override_settings
|
||||
from django.urls import reverse
|
||||
from six import text_type
|
||||
from six.moves import range
|
||||
|
||||
from openedx.core.djangoapps.user_api import accounts
|
||||
from openedx.core.djangoapps.user_api.accounts.tests import testutils
|
||||
from openedx.core.djangoapps.user_api.validation.views import RegistrationValidationThrottle
|
||||
from openedx.core.lib.api import test_utils
|
||||
from util.password_policy_validators import DEFAULT_MAX_PASSWORD_LENGTH
|
||||
|
||||
|
||||
@ddt.ddt
|
||||
class RegistrationValidationViewTests(test_utils.ApiTestCase):
|
||||
"""
|
||||
Tests for validity of user data in registration forms.
|
||||
"""
|
||||
|
||||
endpoint_name = 'registration_validation'
|
||||
path = reverse(endpoint_name)
|
||||
|
||||
def get_validation_decision(self, data):
|
||||
response = self.client.post(self.path, data)
|
||||
return response.data.get('validation_decisions', {})
|
||||
|
||||
def assertValidationDecision(self, data, decision):
|
||||
self.assertEqual(
|
||||
self.get_validation_decision(data),
|
||||
decision
|
||||
)
|
||||
|
||||
def assertNotValidationDecision(self, data, decision):
|
||||
self.assertNotEqual(
|
||||
self.get_validation_decision(data),
|
||||
decision
|
||||
)
|
||||
|
||||
def test_no_decision_for_empty_request(self):
|
||||
self.assertValidationDecision(
|
||||
{},
|
||||
{}
|
||||
)
|
||||
|
||||
def test_no_decision_for_invalid_request(self):
|
||||
self.assertValidationDecision(
|
||||
{'invalid_field': 'random_user_data'},
|
||||
{}
|
||||
)
|
||||
|
||||
@ddt.data(
|
||||
['name', [name for name in testutils.VALID_NAMES]],
|
||||
['email', [email for email in testutils.VALID_EMAILS]],
|
||||
['password', [password for password in testutils.VALID_PASSWORDS]],
|
||||
['username', [username for username in testutils.VALID_USERNAMES]],
|
||||
['country', [country for country in testutils.VALID_COUNTRIES]]
|
||||
)
|
||||
@ddt.unpack
|
||||
def test_positive_validation_decision(self, form_field_name, user_data):
|
||||
"""
|
||||
Test if {0} as any item in {1} gives a positive validation decision.
|
||||
"""
|
||||
self.assertValidationDecision(
|
||||
{form_field_name: user_data},
|
||||
{form_field_name: ''}
|
||||
)
|
||||
|
||||
@ddt.data(
|
||||
# Skip None type for invalidity checks.
|
||||
['name', [name for name in testutils.INVALID_NAMES[1:]]],
|
||||
['email', [email for email in testutils.INVALID_EMAILS[1:]]],
|
||||
['password', [password for password in testutils.INVALID_PASSWORDS[1:]]],
|
||||
['username', [username for username in testutils.INVALID_USERNAMES[1:]]],
|
||||
['country', [country for country in testutils.INVALID_COUNTRIES[1:]]]
|
||||
)
|
||||
@ddt.unpack
|
||||
def test_negative_validation_decision(self, form_field_name, user_data):
|
||||
"""
|
||||
Test if {0} as any item in {1} gives a negative validation decision.
|
||||
"""
|
||||
self.assertNotValidationDecision(
|
||||
{form_field_name: user_data},
|
||||
{form_field_name: ''}
|
||||
)
|
||||
|
||||
@ddt.data(
|
||||
['username', 'username@email.com'], # No conflict
|
||||
['user', 'username@email.com'], # Username conflict
|
||||
['username', 'user@email.com'], # Email conflict
|
||||
['user', 'user@email.com'] # Both conflict
|
||||
)
|
||||
@ddt.unpack
|
||||
def test_existence_conflict(self, username, email):
|
||||
"""
|
||||
Test if username '{0}' and email '{1}' have conflicts with
|
||||
username 'user' and email 'user@email.com'.
|
||||
"""
|
||||
user = User.objects.create_user(username='user', email='user@email.com')
|
||||
self.assertValidationDecision(
|
||||
{
|
||||
'username': username,
|
||||
'email': email
|
||||
},
|
||||
{
|
||||
"username": accounts.USERNAME_CONFLICT_MSG.format(
|
||||
username=user.username
|
||||
) if username == user.username else '',
|
||||
"email": accounts.EMAIL_CONFLICT_MSG.format(
|
||||
email_address=user.email
|
||||
) if email == user.email else ''
|
||||
}
|
||||
)
|
||||
|
||||
@ddt.data('', ('e' * accounts.EMAIL_MAX_LENGTH) + '@email.com')
|
||||
def test_email_bad_length_validation_decision(self, email):
|
||||
self.assertValidationDecision(
|
||||
{'email': email},
|
||||
{'email': accounts.EMAIL_BAD_LENGTH_MSG}
|
||||
)
|
||||
|
||||
def test_email_generically_invalid_validation_decision(self):
|
||||
email = 'email'
|
||||
self.assertValidationDecision(
|
||||
{'email': email},
|
||||
{'email': accounts.EMAIL_INVALID_MSG.format(email=email)}
|
||||
)
|
||||
|
||||
def test_confirm_email_matches_email(self):
|
||||
email = 'user@email.com'
|
||||
self.assertValidationDecision(
|
||||
{'email': email, 'confirm_email': email},
|
||||
{'email': '', 'confirm_email': ''}
|
||||
)
|
||||
|
||||
@ddt.data('', 'users@other.email')
|
||||
def test_confirm_email_doesnt_equal_email(self, confirm_email):
|
||||
self.assertValidationDecision(
|
||||
{'email': 'user@email.com', 'confirm_email': confirm_email},
|
||||
{'email': '', 'confirm_email': text_type(accounts.REQUIRED_FIELD_CONFIRM_EMAIL_MSG)}
|
||||
)
|
||||
|
||||
@ddt.data(
|
||||
'u' * (accounts.USERNAME_MIN_LENGTH - 1),
|
||||
'u' * (accounts.USERNAME_MAX_LENGTH + 1)
|
||||
)
|
||||
def test_username_bad_length_validation_decision(self, username):
|
||||
self.assertValidationDecision(
|
||||
{'username': username},
|
||||
{'username': text_type(accounts.USERNAME_BAD_LENGTH_MSG)}
|
||||
)
|
||||
|
||||
@unittest.skipUnless(settings.FEATURES.get("ENABLE_UNICODE_USERNAME"), "Unicode usernames disabled.")
|
||||
@ddt.data(*testutils.INVALID_USERNAMES_UNICODE)
|
||||
def test_username_invalid_unicode_validation_decision(self, username):
|
||||
self.assertValidationDecision(
|
||||
{'username': username},
|
||||
{'username': text_type(accounts.USERNAME_INVALID_CHARS_UNICODE)}
|
||||
)
|
||||
|
||||
@unittest.skipIf(settings.FEATURES.get("ENABLE_UNICODE_USERNAME"), "Unicode usernames enabled.")
|
||||
@ddt.data(*testutils.INVALID_USERNAMES_ASCII)
|
||||
def test_username_invalid_ascii_validation_decision(self, username):
|
||||
self.assertValidationDecision(
|
||||
{'username': username},
|
||||
{"username": text_type(accounts.USERNAME_INVALID_CHARS_ASCII)}
|
||||
)
|
||||
|
||||
def test_password_empty_validation_decision(self):
|
||||
# 2 is the default setting for minimum length found in lms/envs/common.py
|
||||
# under AUTH_PASSWORD_VALIDATORS.MinimumLengthValidator
|
||||
msg = u'This password is too short. It must contain at least 2 characters.'
|
||||
self.assertValidationDecision(
|
||||
{'password': ''},
|
||||
{"password": msg}
|
||||
)
|
||||
|
||||
def test_password_bad_min_length_validation_decision(self):
|
||||
password = 'p'
|
||||
# 2 is the default setting for minimum length found in lms/envs/common.py
|
||||
# under AUTH_PASSWORD_VALIDATORS.MinimumLengthValidator
|
||||
msg = u'This password is too short. It must contain at least 2 characters.'
|
||||
self.assertValidationDecision(
|
||||
{'password': password},
|
||||
{"password": msg}
|
||||
)
|
||||
|
||||
def test_password_bad_max_length_validation_decision(self):
|
||||
password = 'p' * DEFAULT_MAX_PASSWORD_LENGTH
|
||||
# 75 is the default setting for maximum length found in lms/envs/common.py
|
||||
# under AUTH_PASSWORD_VALIDATORS.MaximumLengthValidator
|
||||
msg = u'This password is too long. It must contain no more than 75 characters.'
|
||||
self.assertValidationDecision(
|
||||
{'password': password},
|
||||
{"password": msg}
|
||||
)
|
||||
|
||||
def test_password_equals_username_validation_decision(self):
|
||||
self.assertValidationDecision(
|
||||
{"username": "somephrase", "password": "somephrase"},
|
||||
{"username": "", "password": u"The password is too similar to the username."}
|
||||
)
|
||||
|
||||
@override_settings(
|
||||
CACHES={
|
||||
'default': {
|
||||
'BACKEND': 'django.core.cache.backends.locmem.LocMemCache',
|
||||
'LOCATION': 'registration_proxy',
|
||||
}
|
||||
}
|
||||
)
|
||||
def test_rate_limiting_registration_view(self):
|
||||
"""
|
||||
Confirm rate limits work as expected for registration
|
||||
end point /api/user/v1/validation/registration/. Note
|
||||
that drf's rate limiting makes use of the default cache
|
||||
to enforce limits; that's why this test needs a "real"
|
||||
default cache (as opposed to the usual-for-tests DummyCache)
|
||||
"""
|
||||
for _ in range(RegistrationValidationThrottle().num_requests):
|
||||
self.request_without_auth('post', self.path)
|
||||
response = self.request_without_auth('post', self.path)
|
||||
self.assertEqual(response.status_code, 429)
|
||||
@@ -1,199 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
An API for client-side validation of (potential) user data.
|
||||
"""
|
||||
|
||||
from __future__ import absolute_import
|
||||
|
||||
from ipware.ip import get_ip
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.throttling import AnonRateThrottle
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from openedx.core.djangoapps.user_api.accounts.api import (
|
||||
get_confirm_email_validation_error,
|
||||
get_country_validation_error,
|
||||
get_email_existence_validation_error,
|
||||
get_email_validation_error,
|
||||
get_name_validation_error,
|
||||
get_password_validation_error,
|
||||
get_username_existence_validation_error,
|
||||
get_username_validation_error
|
||||
)
|
||||
|
||||
|
||||
class RegistrationValidationThrottle(AnonRateThrottle):
|
||||
"""
|
||||
Custom throttle rate for /api/user/v1/validation/registration
|
||||
endpoint's use case.
|
||||
"""
|
||||
|
||||
scope = 'registration_validation'
|
||||
|
||||
def get_ident(self, request):
|
||||
client_ip = get_ip(request)
|
||||
return client_ip
|
||||
|
||||
|
||||
class RegistrationValidationView(APIView):
|
||||
"""
|
||||
**Use Cases**
|
||||
|
||||
Get validation information about user data during registration.
|
||||
Client-side may request validation for any number of form fields,
|
||||
and the API will return a conclusion from its analysis for each
|
||||
input (i.e. valid or not valid, or a custom, detailed message).
|
||||
|
||||
**Example Requests and Responses**
|
||||
|
||||
- Checks the validity of the username and email inputs separately.
|
||||
POST /api/user/v1/validation/registration/
|
||||
>>> {
|
||||
>>> "username": "hi_im_new",
|
||||
>>> "email": "newguy101@edx.org"
|
||||
>>> }
|
||||
RESPONSE
|
||||
>>> {
|
||||
>>> "validation_decisions": {
|
||||
>>> "username": "",
|
||||
>>> "email": ""
|
||||
>>> }
|
||||
>>> }
|
||||
Empty strings indicate that there was no problem with the input.
|
||||
|
||||
- Checks the validity of the password field (its validity depends
|
||||
upon both the username and password fields, so we need both). If
|
||||
only password is input, we don't check for password/username
|
||||
compatibility issues.
|
||||
POST /api/user/v1/validation/registration/
|
||||
>>> {
|
||||
>>> "username": "myname",
|
||||
>>> "password": "myname"
|
||||
>>> }
|
||||
RESPONSE
|
||||
>>> {
|
||||
>>> "validation_decisions": {
|
||||
>>> "username": "",
|
||||
>>> "password": "Password cannot be the same as the username."
|
||||
>>> }
|
||||
>>> }
|
||||
|
||||
- Checks the validity of the username, email, and password fields
|
||||
separately, and also tells whether an account exists. The password
|
||||
field's validity depends upon both the username and password, and
|
||||
the account's existence depends upon both the username and email.
|
||||
POST /api/user/v1/validation/registration/
|
||||
>>> {
|
||||
>>> "username": "hi_im_new",
|
||||
>>> "email": "cto@edx.org",
|
||||
>>> "password": "p"
|
||||
>>> }
|
||||
RESPONSE
|
||||
>>> {
|
||||
>>> "validation_decisions": {
|
||||
>>> "username": "",
|
||||
>>> "email": "It looks like cto@edx.org belongs to an existing account. Try again with a different email address.",
|
||||
>>> "password": "Password must be at least 2 characters long",
|
||||
>>> }
|
||||
>>> }
|
||||
In this example, username is valid and (we assume) there is
|
||||
a preexisting account with that email. The password also seems
|
||||
to contain the username.
|
||||
|
||||
Note that a validation decision is returned *for all* inputs, whether
|
||||
positive or negative.
|
||||
|
||||
**Available Handlers**
|
||||
|
||||
"name":
|
||||
A handler to check the validity of the user's real name.
|
||||
"username":
|
||||
A handler to check the validity of usernames.
|
||||
"email":
|
||||
A handler to check the validity of emails.
|
||||
"confirm_email":
|
||||
A handler to check whether the confirmation email field matches
|
||||
the email field.
|
||||
"password":
|
||||
A handler to check the validity of passwords; a compatibility
|
||||
decision with the username is made if it exists in the input.
|
||||
"country":
|
||||
A handler to check whether the validity of country fields.
|
||||
"""
|
||||
|
||||
# This end-point is available to anonymous users, so no authentication is needed.
|
||||
authentication_classes = []
|
||||
throttle_classes = (RegistrationValidationThrottle,)
|
||||
|
||||
def name_handler(self, request):
|
||||
name = request.data.get('name')
|
||||
return get_name_validation_error(name)
|
||||
|
||||
def username_handler(self, request):
|
||||
username = request.data.get('username')
|
||||
invalid_username_error = get_username_validation_error(username)
|
||||
username_exists_error = get_username_existence_validation_error(username)
|
||||
# We prefer seeing for invalidity first.
|
||||
# Some invalid usernames (like for superusers) may exist.
|
||||
return invalid_username_error or username_exists_error
|
||||
|
||||
def email_handler(self, request):
|
||||
email = request.data.get('email')
|
||||
invalid_email_error = get_email_validation_error(email)
|
||||
email_exists_error = get_email_existence_validation_error(email)
|
||||
# We prefer seeing for invalidity first.
|
||||
# Some invalid emails (like a blank one for superusers) may exist.
|
||||
return invalid_email_error or email_exists_error
|
||||
|
||||
def confirm_email_handler(self, request):
|
||||
email = request.data.get('email')
|
||||
confirm_email = request.data.get('confirm_email')
|
||||
return get_confirm_email_validation_error(confirm_email, email)
|
||||
|
||||
def password_handler(self, request):
|
||||
username = request.data.get('username')
|
||||
email = request.data.get('email')
|
||||
password = request.data.get('password')
|
||||
return get_password_validation_error(password, username, email)
|
||||
|
||||
def country_handler(self, request):
|
||||
country = request.data.get('country')
|
||||
return get_country_validation_error(country)
|
||||
|
||||
validation_handlers = {
|
||||
"name": name_handler,
|
||||
"username": username_handler,
|
||||
"email": email_handler,
|
||||
"confirm_email": confirm_email_handler,
|
||||
"password": password_handler,
|
||||
"country": country_handler
|
||||
}
|
||||
|
||||
def post(self, request):
|
||||
"""
|
||||
POST /api/user/v1/validation/registration/
|
||||
|
||||
Expects request of the form
|
||||
>>> {
|
||||
>>> "name": "Dan the Validator",
|
||||
>>> "username": "mslm",
|
||||
>>> "email": "mslm@gmail.com",
|
||||
>>> "confirm_email": "mslm@gmail.com",
|
||||
>>> "password": "password123",
|
||||
>>> "country": "PK"
|
||||
>>> }
|
||||
where each key is the appropriate form field name and the value is
|
||||
user input. One may enter individual inputs if needed. Some inputs
|
||||
can get extra verification checks if entered along with others,
|
||||
like when the password may not equal the username.
|
||||
"""
|
||||
validation_decisions = {}
|
||||
for form_field_key in self.validation_handlers:
|
||||
# For every field requiring validation from the client,
|
||||
# request a decision for it from the appropriate handler.
|
||||
if form_field_key in request.data:
|
||||
handler = self.validation_handlers[form_field_key]
|
||||
validation_decisions.update({
|
||||
form_field_key: handler(self, request)
|
||||
})
|
||||
return Response({"validation_decisions": validation_decisions})
|
||||
@@ -5,6 +5,9 @@ Note: The split between urls.py and urls_common.py is hopefully temporary.
|
||||
For now, this is needed because of difference in CMS and LMS that have
|
||||
not yet been cleaned up.
|
||||
|
||||
This is also home to urls for endpoints that have been consolidated from other djangoapps,
|
||||
which leads to inconsistent prefixing.
|
||||
|
||||
"""
|
||||
from __future__ import absolute_import
|
||||
|
||||
@@ -17,12 +20,27 @@ from .views import auto_auth, login, logout, password_reset, register
|
||||
urlpatterns = [
|
||||
# Registration
|
||||
url(r'^create_account$', register.RegistrationView.as_view(), name='create_account'),
|
||||
|
||||
# Moved from user_api/legacy_urls.py
|
||||
# `user_api` prefix is preserved for backwards compatibility.
|
||||
url(r'^user_api/v1/account/registration/$', register.RegistrationView.as_view(),
|
||||
name="user_api_registration"),
|
||||
|
||||
# Moved from user_api/urls.py
|
||||
# `api/user` prefix is preserved for backwards compatibility.
|
||||
url(
|
||||
r'^api/user/v1/validation/registration$',
|
||||
register.RegistrationValidationView.as_view(),
|
||||
name='registration_validation'
|
||||
),
|
||||
|
||||
# Login
|
||||
url(r'^login_post$', login.login_user, name='login_post'),
|
||||
url(r'^login_ajax$', login.login_user, name="login"),
|
||||
url(r'^login_ajax/(?P<error>[^/]*)$', login.login_user),
|
||||
|
||||
# Moved from user_api/legacy_urls.py
|
||||
# `user_api` prefix is preserved for backwards compatibility.
|
||||
url(r'^user_api/v1/account/login_session/$', login.LoginSessionView.as_view(),
|
||||
name="user_api_login_session"),
|
||||
|
||||
@@ -31,7 +49,8 @@ urlpatterns = [
|
||||
|
||||
url(r'^logout$', logout.LogoutView.as_view(), name='logout'),
|
||||
|
||||
url(r'^v1/account/password_reset/$', password_reset.PasswordResetView.as_view(),
|
||||
# Moved from user_api/legacy_urls.py
|
||||
url(r'^user_api/v1/account/password_reset/$', password_reset.PasswordResetView.as_view(),
|
||||
name="user_api_password_reset"),
|
||||
|
||||
]
|
||||
|
||||
@@ -25,6 +25,9 @@ from django.utils.translation import ugettext as _
|
||||
from pytz import UTC
|
||||
from requests import HTTPError
|
||||
from six import text_type
|
||||
from ipware.ip import get_ip
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.throttling import AnonRateThrottle
|
||||
from rest_framework.views import APIView
|
||||
from social_core.exceptions import AuthAlreadyAssociated, AuthException
|
||||
from social_django import utils as social_utils
|
||||
@@ -36,6 +39,16 @@ from lms.djangoapps.discussion.notification_prefs.views import enable_notificati
|
||||
from openedx.core.djangoapps.lang_pref import LANGUAGE_KEY
|
||||
from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers
|
||||
from openedx.core.djangoapps.user_api import accounts as accounts_settings
|
||||
from openedx.core.djangoapps.user_api.accounts.api import (
|
||||
get_confirm_email_validation_error,
|
||||
get_country_validation_error,
|
||||
get_email_existence_validation_error,
|
||||
get_email_validation_error,
|
||||
get_name_validation_error,
|
||||
get_password_validation_error,
|
||||
get_username_existence_validation_error,
|
||||
get_username_validation_error
|
||||
)
|
||||
from openedx.core.djangoapps.user_authn.utils import generate_password
|
||||
from openedx.core.djangoapps.user_api.preferences import api as preferences_api
|
||||
from openedx.core.djangoapps.user_authn.cookies import set_logged_in_cookies
|
||||
@@ -517,3 +530,183 @@ class RegistrationView(APIView):
|
||||
# keeping this `success` field in for now, as we have outstanding clients expecting this
|
||||
response_dict['success'] = True
|
||||
return JsonResponse(response_dict, status=status_code)
|
||||
|
||||
|
||||
class RegistrationValidationThrottle(AnonRateThrottle):
|
||||
"""
|
||||
Custom throttle rate for /api/user/v1/validation/registration
|
||||
endpoint's use case.
|
||||
"""
|
||||
|
||||
scope = 'registration_validation'
|
||||
|
||||
def get_ident(self, request):
|
||||
client_ip = get_ip(request)
|
||||
return client_ip
|
||||
|
||||
|
||||
# pylint: disable=line-too-long
|
||||
class RegistrationValidationView(APIView):
|
||||
"""
|
||||
**Use Cases**
|
||||
|
||||
Get validation information about user data during registration.
|
||||
Client-side may request validation for any number of form fields,
|
||||
and the API will return a conclusion from its analysis for each
|
||||
input (i.e. valid or not valid, or a custom, detailed message).
|
||||
|
||||
**Example Requests and Responses**
|
||||
|
||||
- Checks the validity of the username and email inputs separately.
|
||||
POST /api/user/v1/validation/registration/
|
||||
>>> {
|
||||
>>> "username": "hi_im_new",
|
||||
>>> "email": "newguy101@edx.org"
|
||||
>>> }
|
||||
RESPONSE
|
||||
>>> {
|
||||
>>> "validation_decisions": {
|
||||
>>> "username": "",
|
||||
>>> "email": ""
|
||||
>>> }
|
||||
>>> }
|
||||
Empty strings indicate that there was no problem with the input.
|
||||
|
||||
- Checks the validity of the password field (its validity depends
|
||||
upon both the username and password fields, so we need both). If
|
||||
only password is input, we don't check for password/username
|
||||
compatibility issues.
|
||||
POST /api/user/v1/validation/registration/
|
||||
>>> {
|
||||
>>> "username": "myname",
|
||||
>>> "password": "myname"
|
||||
>>> }
|
||||
RESPONSE
|
||||
>>> {
|
||||
>>> "validation_decisions": {
|
||||
>>> "username": "",
|
||||
>>> "password": "Password cannot be the same as the username."
|
||||
>>> }
|
||||
>>> }
|
||||
|
||||
- Checks the validity of the username, email, and password fields
|
||||
separately, and also tells whether an account exists. The password
|
||||
field's validity depends upon both the username and password, and
|
||||
the account's existence depends upon both the username and email.
|
||||
POST /api/user/v1/validation/registration/
|
||||
>>> {
|
||||
>>> "username": "hi_im_new",
|
||||
>>> "email": "cto@edx.org",
|
||||
>>> "password": "p"
|
||||
>>> }
|
||||
RESPONSE
|
||||
>>> {
|
||||
>>> "validation_decisions": {
|
||||
>>> "username": "",
|
||||
>>> "email": "It looks like cto@edx.org belongs to an existing account. Try again with a different email address.",
|
||||
>>> "password": "Password must be at least 2 characters long",
|
||||
>>> }
|
||||
>>> }
|
||||
In this example, username is valid and (we assume) there is
|
||||
a preexisting account with that email. The password also seems
|
||||
to contain the username.
|
||||
|
||||
Note that a validation decision is returned *for all* inputs, whether
|
||||
positive or negative.
|
||||
|
||||
**Available Handlers**
|
||||
|
||||
"name":
|
||||
A handler to check the validity of the user's real name.
|
||||
"username":
|
||||
A handler to check the validity of usernames.
|
||||
"email":
|
||||
A handler to check the validity of emails.
|
||||
"confirm_email":
|
||||
A handler to check whether the confirmation email field matches
|
||||
the email field.
|
||||
"password":
|
||||
A handler to check the validity of passwords; a compatibility
|
||||
decision with the username is made if it exists in the input.
|
||||
"country":
|
||||
A handler to check whether the validity of country fields.
|
||||
"""
|
||||
|
||||
# This end-point is available to anonymous users, so no authentication is needed.
|
||||
authentication_classes = []
|
||||
throttle_classes = (RegistrationValidationThrottle,)
|
||||
|
||||
def name_handler(self, request):
|
||||
name = request.data.get('name')
|
||||
return get_name_validation_error(name)
|
||||
|
||||
def username_handler(self, request):
|
||||
""" Validates whether the username is valid. """
|
||||
username = request.data.get('username')
|
||||
invalid_username_error = get_username_validation_error(username)
|
||||
username_exists_error = get_username_existence_validation_error(username)
|
||||
# We prefer seeing for invalidity first.
|
||||
# Some invalid usernames (like for superusers) may exist.
|
||||
return invalid_username_error or username_exists_error
|
||||
|
||||
def email_handler(self, request):
|
||||
""" Validates whether the email address is valid. """
|
||||
email = request.data.get('email')
|
||||
invalid_email_error = get_email_validation_error(email)
|
||||
email_exists_error = get_email_existence_validation_error(email)
|
||||
# We prefer seeing for invalidity first.
|
||||
# Some invalid emails (like a blank one for superusers) may exist.
|
||||
return invalid_email_error or email_exists_error
|
||||
|
||||
def confirm_email_handler(self, request):
|
||||
email = request.data.get('email')
|
||||
confirm_email = request.data.get('confirm_email')
|
||||
return get_confirm_email_validation_error(confirm_email, email)
|
||||
|
||||
def password_handler(self, request):
|
||||
username = request.data.get('username')
|
||||
email = request.data.get('email')
|
||||
password = request.data.get('password')
|
||||
return get_password_validation_error(password, username, email)
|
||||
|
||||
def country_handler(self, request):
|
||||
country = request.data.get('country')
|
||||
return get_country_validation_error(country)
|
||||
|
||||
validation_handlers = {
|
||||
"name": name_handler,
|
||||
"username": username_handler,
|
||||
"email": email_handler,
|
||||
"confirm_email": confirm_email_handler,
|
||||
"password": password_handler,
|
||||
"country": country_handler
|
||||
}
|
||||
|
||||
def post(self, request):
|
||||
"""
|
||||
POST /api/user/v1/validation/registration/
|
||||
|
||||
Expects request of the form
|
||||
>>> {
|
||||
>>> "name": "Dan the Validator",
|
||||
>>> "username": "mslm",
|
||||
>>> "email": "mslm@gmail.com",
|
||||
>>> "confirm_email": "mslm@gmail.com",
|
||||
>>> "password": "password123",
|
||||
>>> "country": "PK"
|
||||
>>> }
|
||||
where each key is the appropriate form field name and the value is
|
||||
user input. One may enter individual inputs if needed. Some inputs
|
||||
can get extra verification checks if entered along with others,
|
||||
like when the password may not equal the username.
|
||||
"""
|
||||
validation_decisions = {}
|
||||
for form_field_key in self.validation_handlers:
|
||||
# For every field requiring validation from the client,
|
||||
# request a decision for it from the appropriate handler.
|
||||
if form_field_key in request.data:
|
||||
handler = self.validation_handlers[form_field_key]
|
||||
validation_decisions.update({
|
||||
form_field_key: handler(self, request)
|
||||
})
|
||||
return Response({"validation_decisions": validation_decisions})
|
||||
|
||||
@@ -3,13 +3,15 @@
|
||||
from __future__ import absolute_import
|
||||
|
||||
import json
|
||||
from unittest import skipUnless
|
||||
from unittest import skipIf, skipUnless
|
||||
from datetime import datetime
|
||||
|
||||
import ddt
|
||||
import httpretty
|
||||
import mock
|
||||
import six
|
||||
from six.moves import range
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import User
|
||||
from django.core import mail
|
||||
@@ -24,14 +26,22 @@ from social_django.models import Partial, UserSocialAuth
|
||||
from openedx.core.djangoapps.site_configuration.helpers import get_value
|
||||
from openedx.core.djangoapps.site_configuration.tests.test_util import with_site_configuration
|
||||
from openedx.core.djangoapps.user_api.accounts import (
|
||||
EMAIL_BAD_LENGTH_MSG,
|
||||
EMAIL_CONFLICT_MSG,
|
||||
EMAIL_INVALID_MSG,
|
||||
EMAIL_MAX_LENGTH,
|
||||
EMAIL_MIN_LENGTH,
|
||||
NAME_MAX_LENGTH,
|
||||
REQUIRED_FIELD_CONFIRM_EMAIL_MSG,
|
||||
USERNAME_MAX_LENGTH,
|
||||
USERNAME_MIN_LENGTH,
|
||||
USERNAME_BAD_LENGTH_MSG,
|
||||
USERNAME_CONFLICT_MSG,
|
||||
USERNAME_INVALID_CHARS_ASCII,
|
||||
USERNAME_INVALID_CHARS_UNICODE,
|
||||
)
|
||||
from openedx.core.djangoapps.user_api.accounts.api import get_account_settings
|
||||
from openedx.core.djangoapps.user_api.accounts.tests import testutils
|
||||
from openedx.core.djangoapps.user_api.accounts.tests.retirement_helpers import ( # pylint: disable=unused-import
|
||||
RetirementTestCase,
|
||||
fake_requested_retirement,
|
||||
@@ -40,7 +50,9 @@ from openedx.core.djangoapps.user_api.accounts.tests.retirement_helpers import (
|
||||
from openedx.core.djangoapps.user_api.tests.test_helpers import TestCaseForm
|
||||
from openedx.core.djangoapps.user_api.tests.test_constants import SORTED_COUNTRIES
|
||||
from openedx.core.djangoapps.user_api.tests.test_views import UserAPITestCase
|
||||
from openedx.core.djangoapps.user_authn.views.register import RegistrationValidationThrottle
|
||||
from openedx.core.djangolib.testing.utils import CacheIsolationTestCase, skip_unless_lms
|
||||
from openedx.core.lib.api import test_utils
|
||||
from student.tests.factories import UserFactory
|
||||
from third_party_auth.tests.testutil import ThirdPartyAuthTestMixin, simulate_running_pipeline
|
||||
from third_party_auth.tests.utils import (
|
||||
@@ -49,6 +61,7 @@ from third_party_auth.tests.utils import (
|
||||
ThirdPartyOAuthTestMixinGoogle
|
||||
)
|
||||
from util.password_policy_validators import (
|
||||
DEFAULT_MAX_PASSWORD_LENGTH,
|
||||
create_validator_config,
|
||||
password_validators_instruction_texts,
|
||||
password_validators_restrictions
|
||||
@@ -1845,3 +1858,217 @@ class TestGoogleRegistrationView(
|
||||
):
|
||||
"""Tests the User API registration endpoint with Google authentication."""
|
||||
__test__ = True
|
||||
|
||||
|
||||
@ddt.ddt
|
||||
class RegistrationValidationViewTests(test_utils.ApiTestCase):
|
||||
"""
|
||||
Tests for validity of user data in registration forms.
|
||||
"""
|
||||
|
||||
endpoint_name = 'registration_validation'
|
||||
path = reverse(endpoint_name)
|
||||
|
||||
def get_validation_decision(self, data):
|
||||
response = self.client.post(self.path, data)
|
||||
return response.data.get('validation_decisions', {})
|
||||
|
||||
def assertValidationDecision(self, data, decision):
|
||||
self.assertEqual(
|
||||
self.get_validation_decision(data),
|
||||
decision
|
||||
)
|
||||
|
||||
def assertNotValidationDecision(self, data, decision):
|
||||
self.assertNotEqual(
|
||||
self.get_validation_decision(data),
|
||||
decision
|
||||
)
|
||||
|
||||
def test_no_decision_for_empty_request(self):
|
||||
self.assertValidationDecision(
|
||||
{},
|
||||
{}
|
||||
)
|
||||
|
||||
def test_no_decision_for_invalid_request(self):
|
||||
self.assertValidationDecision(
|
||||
{'invalid_field': 'random_user_data'},
|
||||
{}
|
||||
)
|
||||
|
||||
@ddt.data(
|
||||
['name', [name for name in testutils.VALID_NAMES]],
|
||||
['email', [email for email in testutils.VALID_EMAILS]],
|
||||
['password', [password for password in testutils.VALID_PASSWORDS]],
|
||||
['username', [username for username in testutils.VALID_USERNAMES]],
|
||||
['country', [country for country in testutils.VALID_COUNTRIES]]
|
||||
)
|
||||
@ddt.unpack
|
||||
def test_positive_validation_decision(self, form_field_name, user_data):
|
||||
"""
|
||||
Test if {0} as any item in {1} gives a positive validation decision.
|
||||
"""
|
||||
self.assertValidationDecision(
|
||||
{form_field_name: user_data},
|
||||
{form_field_name: ''}
|
||||
)
|
||||
|
||||
@ddt.data(
|
||||
# Skip None type for invalidity checks.
|
||||
['name', [name for name in testutils.INVALID_NAMES[1:]]],
|
||||
['email', [email for email in testutils.INVALID_EMAILS[1:]]],
|
||||
['password', [password for password in testutils.INVALID_PASSWORDS[1:]]],
|
||||
['username', [username for username in testutils.INVALID_USERNAMES[1:]]],
|
||||
['country', [country for country in testutils.INVALID_COUNTRIES[1:]]]
|
||||
)
|
||||
@ddt.unpack
|
||||
def test_negative_validation_decision(self, form_field_name, user_data):
|
||||
"""
|
||||
Test if {0} as any item in {1} gives a negative validation decision.
|
||||
"""
|
||||
self.assertNotValidationDecision(
|
||||
{form_field_name: user_data},
|
||||
{form_field_name: ''}
|
||||
)
|
||||
|
||||
@ddt.data(
|
||||
['username', 'username@email.com'], # No conflict
|
||||
['user', 'username@email.com'], # Username conflict
|
||||
['username', 'user@email.com'], # Email conflict
|
||||
['user', 'user@email.com'] # Both conflict
|
||||
)
|
||||
@ddt.unpack
|
||||
def test_existence_conflict(self, username, email):
|
||||
"""
|
||||
Test if username '{0}' and email '{1}' have conflicts with
|
||||
username 'user' and email 'user@email.com'.
|
||||
"""
|
||||
user = User.objects.create_user(username='user', email='user@email.com')
|
||||
self.assertValidationDecision(
|
||||
{
|
||||
'username': username,
|
||||
'email': email
|
||||
},
|
||||
{
|
||||
# pylint: disable=no-member
|
||||
"username": USERNAME_CONFLICT_MSG.format(
|
||||
username=user.username
|
||||
) if username == user.username else '',
|
||||
# pylint: disable=no-member
|
||||
"email": EMAIL_CONFLICT_MSG.format(
|
||||
email_address=user.email
|
||||
) if email == user.email else ''
|
||||
}
|
||||
)
|
||||
|
||||
@ddt.data('', ('e' * EMAIL_MAX_LENGTH) + '@email.com')
|
||||
def test_email_bad_length_validation_decision(self, email):
|
||||
self.assertValidationDecision(
|
||||
{'email': email},
|
||||
{'email': EMAIL_BAD_LENGTH_MSG}
|
||||
)
|
||||
|
||||
def test_email_generically_invalid_validation_decision(self):
|
||||
email = 'email'
|
||||
self.assertValidationDecision(
|
||||
{'email': email},
|
||||
# pylint: disable=no-member
|
||||
{'email': EMAIL_INVALID_MSG.format(email=email)}
|
||||
)
|
||||
|
||||
def test_confirm_email_matches_email(self):
|
||||
email = 'user@email.com'
|
||||
self.assertValidationDecision(
|
||||
{'email': email, 'confirm_email': email},
|
||||
{'email': '', 'confirm_email': ''}
|
||||
)
|
||||
|
||||
@ddt.data('', 'users@other.email')
|
||||
def test_confirm_email_doesnt_equal_email(self, confirm_email):
|
||||
self.assertValidationDecision(
|
||||
{'email': 'user@email.com', 'confirm_email': confirm_email},
|
||||
{'email': '', 'confirm_email': six.text_type(REQUIRED_FIELD_CONFIRM_EMAIL_MSG)}
|
||||
)
|
||||
|
||||
@ddt.data(
|
||||
'u' * (USERNAME_MIN_LENGTH - 1),
|
||||
'u' * (USERNAME_MAX_LENGTH + 1)
|
||||
)
|
||||
def test_username_bad_length_validation_decision(self, username):
|
||||
self.assertValidationDecision(
|
||||
{'username': username},
|
||||
{'username': six.text_type(USERNAME_BAD_LENGTH_MSG)}
|
||||
)
|
||||
|
||||
@skipUnless(settings.FEATURES.get("ENABLE_UNICODE_USERNAME"), "Unicode usernames disabled.")
|
||||
@ddt.data(*testutils.INVALID_USERNAMES_UNICODE)
|
||||
def test_username_invalid_unicode_validation_decision(self, username):
|
||||
self.assertValidationDecision(
|
||||
{'username': username},
|
||||
{'username': six.text_type(USERNAME_INVALID_CHARS_UNICODE)}
|
||||
)
|
||||
|
||||
@skipIf(settings.FEATURES.get("ENABLE_UNICODE_USERNAME"), "Unicode usernames enabled.")
|
||||
@ddt.data(*testutils.INVALID_USERNAMES_ASCII)
|
||||
def test_username_invalid_ascii_validation_decision(self, username):
|
||||
self.assertValidationDecision(
|
||||
{'username': username},
|
||||
{"username": six.text_type(USERNAME_INVALID_CHARS_ASCII)}
|
||||
)
|
||||
|
||||
def test_password_empty_validation_decision(self):
|
||||
# 2 is the default setting for minimum length found in lms/envs/common.py
|
||||
# under AUTH_PASSWORD_VALIDATORS.MinimumLengthValidator
|
||||
msg = u'This password is too short. It must contain at least 2 characters.'
|
||||
self.assertValidationDecision(
|
||||
{'password': ''},
|
||||
{"password": msg}
|
||||
)
|
||||
|
||||
def test_password_bad_min_length_validation_decision(self):
|
||||
password = 'p'
|
||||
# 2 is the default setting for minimum length found in lms/envs/common.py
|
||||
# under AUTH_PASSWORD_VALIDATORS.MinimumLengthValidator
|
||||
msg = u'This password is too short. It must contain at least 2 characters.'
|
||||
self.assertValidationDecision(
|
||||
{'password': password},
|
||||
{"password": msg}
|
||||
)
|
||||
|
||||
def test_password_bad_max_length_validation_decision(self):
|
||||
password = 'p' * DEFAULT_MAX_PASSWORD_LENGTH
|
||||
# 75 is the default setting for maximum length found in lms/envs/common.py
|
||||
# under AUTH_PASSWORD_VALIDATORS.MaximumLengthValidator
|
||||
msg = u'This password is too long. It must contain no more than 75 characters.'
|
||||
self.assertValidationDecision(
|
||||
{'password': password},
|
||||
{"password": msg}
|
||||
)
|
||||
|
||||
def test_password_equals_username_validation_decision(self):
|
||||
self.assertValidationDecision(
|
||||
{"username": "somephrase", "password": "somephrase"},
|
||||
{"username": "", "password": u"The password is too similar to the username."}
|
||||
)
|
||||
|
||||
@override_settings(
|
||||
CACHES={
|
||||
'default': {
|
||||
'BACKEND': 'django.core.cache.backends.locmem.LocMemCache',
|
||||
'LOCATION': 'registration_proxy',
|
||||
}
|
||||
}
|
||||
)
|
||||
def test_rate_limiting_registration_view(self):
|
||||
"""
|
||||
Confirm rate limits work as expected for registration
|
||||
end point /api/user/v1/validation/registration/. Note
|
||||
that drf's rate limiting makes use of the default cache
|
||||
to enforce limits; that's why this test needs a "real"
|
||||
default cache (as opposed to the usual-for-tests DummyCache)
|
||||
"""
|
||||
for _ in range(RegistrationValidationThrottle().num_requests):
|
||||
self.request_without_auth('post', self.path)
|
||||
response = self.request_without_auth('post', self.path)
|
||||
self.assertEqual(response.status_code, 429)
|
||||
|
||||
Reference in New Issue
Block a user