Commit 39ac333b by Uman Shahzad

Add backend AJAX API endpoint for client-side form validation.

In particular, implement a validation API for registration,
where a client makes AJAX calls to the endpoints requesting
validation decisions on each input. Responses are strings
dependent on the type of validation error; if no error,
then empty string to indicate OK.
parent 78708e41
...@@ -23,18 +23,6 @@ from student.models import CourseEnrollmentAllowed ...@@ -23,18 +23,6 @@ from student.models import CourseEnrollmentAllowed
from util.password_policy_validators import validate_password_strength from util.password_policy_validators import validate_password_strength
USERNAME_TOO_SHORT_MSG = _("Username must be minimum of two characters long")
USERNAME_TOO_LONG_MSG = _("Username cannot be more than %(limit_value)s characters long")
# Translators: This message is shown when the Unicode usernames are NOT allowed
USERNAME_INVALID_CHARS_ASCII = _("Usernames can only contain Roman letters, western numerals (0-9), "
"underscores (_), and hyphens (-).")
# Translators: This message is shown only when the Unicode usernames are allowed
USERNAME_INVALID_CHARS_UNICODE = _("Usernames can only contain letters, numerals, underscore (_), numbers "
"and @/./+/-/_ characters.")
class PasswordResetFormNoActive(PasswordResetForm): class PasswordResetFormNoActive(PasswordResetForm):
error_messages = { error_messages = {
'unknown': _("That e-mail address doesn't have an associated " 'unknown': _("That e-mail address doesn't have an associated "
...@@ -127,12 +115,12 @@ def validate_username(username): ...@@ -127,12 +115,12 @@ def validate_username(username):
username_re = slug_re username_re = slug_re
flags = None flags = None
message = USERNAME_INVALID_CHARS_ASCII message = accounts_settings.USERNAME_INVALID_CHARS_ASCII
if settings.FEATURES.get("ENABLE_UNICODE_USERNAME"): if settings.FEATURES.get("ENABLE_UNICODE_USERNAME"):
username_re = r"^{regex}$".format(regex=settings.USERNAME_REGEX_PARTIAL) username_re = r"^{regex}$".format(regex=settings.USERNAME_REGEX_PARTIAL)
flags = re.UNICODE flags = re.UNICODE
message = USERNAME_INVALID_CHARS_UNICODE message = accounts_settings.USERNAME_INVALID_CHARS_UNICODE
validator = RegexValidator( validator = RegexValidator(
regex=username_re, regex=username_re,
...@@ -156,9 +144,9 @@ class UsernameField(forms.CharField): ...@@ -156,9 +144,9 @@ class UsernameField(forms.CharField):
min_length=accounts_settings.USERNAME_MIN_LENGTH, min_length=accounts_settings.USERNAME_MIN_LENGTH,
max_length=accounts_settings.USERNAME_MAX_LENGTH, max_length=accounts_settings.USERNAME_MAX_LENGTH,
error_messages={ error_messages={
"required": USERNAME_TOO_SHORT_MSG, "required": accounts_settings.USERNAME_BAD_LENGTH_MSG,
"min_length": USERNAME_TOO_SHORT_MSG, "min_length": accounts_settings.USERNAME_BAD_LENGTH_MSG,
"max_length": USERNAME_TOO_LONG_MSG, "max_length": accounts_settings.USERNAME_BAD_LENGTH_MSG,
} }
) )
......
...@@ -22,8 +22,8 @@ from notification_prefs import NOTIFICATION_PREF_KEY ...@@ -22,8 +22,8 @@ from notification_prefs import NOTIFICATION_PREF_KEY
from openedx.core.djangoapps.external_auth.models import ExternalAuthMap from openedx.core.djangoapps.external_auth.models import ExternalAuthMap
from openedx.core.djangoapps.lang_pref import LANGUAGE_KEY from openedx.core.djangoapps.lang_pref import LANGUAGE_KEY
from openedx.core.djangoapps.site_configuration.tests.mixins import SiteMixin from openedx.core.djangoapps.site_configuration.tests.mixins import SiteMixin
from openedx.core.djangoapps.user_api.accounts import USERNAME_INVALID_CHARS_ASCII, USERNAME_INVALID_CHARS_UNICODE
from openedx.core.djangoapps.user_api.preferences.api import get_user_preference from openedx.core.djangoapps.user_api.preferences.api import get_user_preference
from student.forms import USERNAME_INVALID_CHARS_ASCII, USERNAME_INVALID_CHARS_UNICODE
from student.models import UserAttribute from student.models import UserAttribute
from student.views import REGISTRATION_AFFILIATE_ID, REGISTRATION_UTM_CREATED_AT, REGISTRATION_UTM_PARAMETERS from student.views import REGISTRATION_AFFILIATE_ID, REGISTRATION_UTM_CREATED_AT, REGISTRATION_UTM_PARAMETERS
......
...@@ -36,7 +36,6 @@ from openedx.core.djangoapps.oauth_dispatch.tests import factories as dot_factor ...@@ -36,7 +36,6 @@ from openedx.core.djangoapps.oauth_dispatch.tests import factories as dot_factor
from openedx.core.djangoapps.programs.tests.mixins import ProgramsApiConfigMixin from openedx.core.djangoapps.programs.tests.mixins import ProgramsApiConfigMixin
from openedx.core.djangoapps.site_configuration.tests.mixins import SiteMixin from openedx.core.djangoapps.site_configuration.tests.mixins import SiteMixin
from openedx.core.djangoapps.theming.tests.test_util import with_comprehensive_theme_context from openedx.core.djangoapps.theming.tests.test_util import with_comprehensive_theme_context
from openedx.core.djangoapps.user_api.accounts import EMAIL_MAX_LENGTH
from openedx.core.djangoapps.user_api.accounts.api import activate_account, create_account from openedx.core.djangoapps.user_api.accounts.api import activate_account, create_account
from openedx.core.djangolib.js_utils import dump_js_escaped_json from openedx.core.djangolib.js_utils import dump_js_escaped_json
from openedx.core.djangolib.testing.utils import CacheIsolationTestCase from openedx.core.djangolib.testing.utils import CacheIsolationTestCase
...@@ -62,24 +61,6 @@ class StudentAccountUpdateTest(CacheIsolationTestCase, UrlResetMixin): ...@@ -62,24 +61,6 @@ class StudentAccountUpdateTest(CacheIsolationTestCase, UrlResetMixin):
NEW_EMAIL = u"walt@savewalterwhite.com" NEW_EMAIL = u"walt@savewalterwhite.com"
INVALID_ATTEMPTS = 100 INVALID_ATTEMPTS = 100
INVALID_EMAILS = [
None,
u"",
u"a",
"no_domain",
"no+domain",
"@",
"@domain.com",
"test@no_extension",
# Long email -- subtract the length of the @domain
# except for one character (so we exceed the max length limit)
u"{user}@example.com".format(
user=(u'e' * (EMAIL_MAX_LENGTH - 11))
)
]
INVALID_KEY = u"123abc" INVALID_KEY = u"123abc"
URLCONF_MODULES = ['student_accounts.urls'] URLCONF_MODULES = ['student_accounts.urls']
......
...@@ -2,6 +2,9 @@ ...@@ -2,6 +2,9 @@
Account constants Account constants
""" """
from django.utils.translation import ugettext as _
# The minimum and maximum length for the name ("full name") account field # The minimum and maximum length for the name ("full name") account field
NAME_MIN_LENGTH = 2 NAME_MIN_LENGTH = 2
NAME_MAX_LENGTH = 255 NAME_MAX_LENGTH = 255
...@@ -25,3 +28,47 @@ ALL_USERS_VISIBILITY = 'all_users' ...@@ -25,3 +28,47 @@ ALL_USERS_VISIBILITY = 'all_users'
# Indicates the user's preference that all their account information be private. # Indicates the user's preference that all their account information be private.
PRIVATE_VISIBILITY = 'private' PRIVATE_VISIBILITY = 'private'
# Translators: This message is shown when the Unicode usernames are NOT allowed.
# It is shown to users who attempt to create a new account using invalid characters
# in the username.
USERNAME_INVALID_CHARS_ASCII = _(
u"Usernames can only contain letters (A-Z, a-z), numerals (0-9), underscores (_), and hyphens (-)."
)
# Translators: This message is shown only when the Unicode usernames are allowed.
# It is shown to users who attempt to create a new account using invalid characters
# in the username.
USERNAME_INVALID_CHARS_UNICODE = _(
u"Usernames can only contain letters, numerals, and @/./+/-/_ characters."
)
# Translators: This message is shown to users who attempt to create a new account using
# an invalid email format.
EMAIL_INVALID_MSG = _(u"Email '{email}' format is not valid")
# Translators: This message is shown to users who attempt to create a new
# account using an username/email associated with an existing account.
EMAIL_CONFLICT_MSG = _(
u"It looks like {email_address} belongs to an existing account. "
u"Try again with a different email address."
)
USERNAME_CONFLICT_MSG = _(
u"It looks like {username} belongs to an existing account. "
u"Try again with a different username."
)
# Translators: This message is shown to users who enter a username/email/password
# with an inappropriate length (too short or too long).
USERNAME_BAD_LENGTH_MSG = _(u"Username '{username}' must be between {min} and {max} characters long")
EMAIL_BAD_LENGTH_MSG = _(u"Email '{email}' must be between {min} and {max} characters long")
PASSWORD_BAD_LENGTH_MSG = _(u"Password must be between {min} and {max} characters long")
# These strings are normally not user-facing.
USERNAME_BAD_TYPE_MSG = u"Username must be a string"
EMAIL_BAD_TYPE_MSG = u"Email must be a string"
PASSWORD_BAD_TYPE_MSG = u"Password must be a string"
# Translators: This message is shown to users who enter a password matching
# the username they enter(ed).
PASSWORD_CANT_EQUAL_USERNAME_MSG = _(u"Password cannot be the same as the username")
# -*- coding: utf-8 -*-
""" """
Programmatic integration point for User API Accounts sub-application Programmatic integration point for User API Accounts sub-application
""" """
...@@ -20,16 +21,23 @@ from util.model_utils import emit_setting_changed_event ...@@ -20,16 +21,23 @@ from util.model_utils import emit_setting_changed_event
from openedx.core.lib.api.view_utils import add_serializer_errors from openedx.core.lib.api.view_utils import add_serializer_errors
from ..errors import ( from ..errors import (
AccountUpdateError, AccountValidationError, AccountUsernameInvalid, AccountPasswordInvalid, AccountUpdateError, AccountValidationError,
AccountEmailInvalid, AccountUserAlreadyExists, AccountDataBadLength, AccountDataBadType,
AccountUsernameInvalid, AccountPasswordInvalid, AccountEmailInvalid,
AccountUserAlreadyExists, AccountUsernameAlreadyExists, AccountEmailAlreadyExists,
UserAPIInternalError, UserAPIRequestError, UserNotFound, UserNotAuthorized UserAPIInternalError, UserAPIRequestError, UserNotFound, UserNotAuthorized
) )
from ..forms import PasswordResetFormNoActive from ..forms import PasswordResetFormNoActive
from ..helpers import intercept_errors from ..helpers import intercept_errors
from . import ( from . import (
EMAIL_MIN_LENGTH, EMAIL_MAX_LENGTH, PASSWORD_MIN_LENGTH, PASSWORD_MAX_LENGTH, EMAIL_BAD_LENGTH_MSG, PASSWORD_BAD_LENGTH_MSG, USERNAME_BAD_LENGTH_MSG,
USERNAME_MIN_LENGTH, USERNAME_MAX_LENGTH EMAIL_BAD_TYPE_MSG, PASSWORD_BAD_TYPE_MSG, USERNAME_BAD_TYPE_MSG,
EMAIL_CONFLICT_MSG, USERNAME_CONFLICT_MSG,
EMAIL_INVALID_MSG, USERNAME_INVALID_MSG,
EMAIL_MIN_LENGTH, PASSWORD_MIN_LENGTH, USERNAME_MIN_LENGTH,
EMAIL_MAX_LENGTH, PASSWORD_MAX_LENGTH, USERNAME_MAX_LENGTH,
PASSWORD_CANT_EQUAL_USERNAME_MSG
) )
from .serializers import ( from .serializers import (
AccountLegacyProfileSerializer, AccountUserSerializer, AccountLegacyProfileSerializer, AccountUserSerializer,
...@@ -70,6 +78,7 @@ def get_account_settings(request, usernames=None, configuration=None, view=None) ...@@ -70,6 +78,7 @@ def get_account_settings(request, usernames=None, configuration=None, view=None)
UserNotFound: no user with username `username` exists (or `request.user.username` if UserNotFound: no user with username `username` exists (or `request.user.username` if
`username` is not specified) `username` is not specified)
UserAPIInternalError: the operation failed due to an unexpected error. UserAPIInternalError: the operation failed due to an unexpected error.
""" """
requesting_user = request.user requesting_user = request.user
usernames = usernames or [requesting_user.username] usernames = usernames or [requesting_user.username]
...@@ -122,6 +131,7 @@ def update_account_settings(requesting_user, update, username=None): ...@@ -122,6 +131,7 @@ def update_account_settings(requesting_user, update, username=None):
in particular, the user account (not including e-mail address) may have successfully been updated, in particular, the user account (not including e-mail address) may have successfully been updated,
but then the e-mail change request, which is processed last, may throw an error. but then the e-mail change request, which is processed last, may throw an error.
UserAPIInternalError: the operation failed due to an unexpected error. UserAPIInternalError: the operation failed due to an unexpected error.
""" """
if username is None: if username is None:
username = requesting_user.username username = requesting_user.username
...@@ -243,20 +253,6 @@ def update_account_settings(requesting_user, update, username=None): ...@@ -243,20 +253,6 @@ def update_account_settings(requesting_user, update, username=None):
) )
def _get_user_and_profile(username):
"""
Helper method to return the legacy user and profile objects based on username.
"""
try:
existing_user = User.objects.get(username=username)
except ObjectDoesNotExist:
raise UserNotFound()
existing_user_profile, _ = UserProfile.objects.get_or_create(user=existing_user)
return existing_user, existing_user_profile
@intercept_errors(UserAPIInternalError, ignore_errors=[UserAPIRequestError]) @intercept_errors(UserAPIInternalError, ignore_errors=[UserAPIRequestError])
@transaction.atomic @transaction.atomic
def create_account(username, password, email): def create_account(username, password, email):
...@@ -296,6 +292,7 @@ def create_account(username, password, email): ...@@ -296,6 +292,7 @@ def create_account(username, password, email):
AccountEmailInvalid AccountEmailInvalid
AccountPasswordInvalid AccountPasswordInvalid
UserAPIInternalError: the operation failed due to an unexpected error. UserAPIInternalError: the operation failed due to an unexpected error.
""" """
# Check if ALLOW_PUBLIC_ACCOUNT_CREATION flag turned off to restrict user account creation # Check if ALLOW_PUBLIC_ACCOUNT_CREATION flag turned off to restrict user account creation
if not configuration_helpers.get_value( if not configuration_helpers.get_value(
...@@ -350,10 +347,13 @@ def check_account_exists(username=None, email=None): ...@@ -350,10 +347,13 @@ def check_account_exists(username=None, email=None):
""" """
conflicts = [] conflicts = []
if email is not None and User.objects.filter(email=email).exists(): try:
_validate_email_doesnt_exist(email)
except AccountEmailAlreadyExists:
conflicts.append("email") conflicts.append("email")
try:
if username is not None and User.objects.filter(username=username).exists(): _validate_username_doesnt_exist(username)
except AccountUsernameAlreadyExists:
conflicts.append("username") conflicts.append("username")
return conflicts return conflicts
...@@ -372,6 +372,7 @@ def activate_account(activation_key): ...@@ -372,6 +372,7 @@ def activate_account(activation_key):
Raises: Raises:
UserNotAuthorized UserNotAuthorized
UserAPIInternalError: the operation failed due to an unexpected error. UserAPIInternalError: the operation failed due to an unexpected error.
""" """
try: try:
registration = Registration.objects.get(activation_key=activation_key) registration = Registration.objects.get(activation_key=activation_key)
...@@ -400,6 +401,7 @@ def request_password_change(email, orig_host, is_secure): ...@@ -400,6 +401,7 @@ def request_password_change(email, orig_host, is_secure):
UserNotFound UserNotFound
AccountRequestError AccountRequestError
UserAPIInternalError: the operation failed due to an unexpected error. UserAPIInternalError: the operation failed due to an unexpected error.
""" """
# Binding data to a form requires that the data be passed as a dictionary # Binding data to a form requires that the data be passed as a dictionary
# to the Form class constructor. # to the Form class constructor.
...@@ -419,6 +421,101 @@ def request_password_change(email, orig_host, is_secure): ...@@ -419,6 +421,101 @@ def request_password_change(email, orig_host, is_secure):
raise UserNotFound raise UserNotFound
def get_username_validation_error(username, default=''):
"""Get the built-in validation error message for when
the username is invalid in some way.
:param username: The proposed username (unicode).
:param default: The message to default to in case of no error.
:return: Validation error message.
"""
try:
_validate_username(username)
except AccountUsernameInvalid as invalid_username_err:
return invalid_username_err.message
return default
def get_email_validation_error(email, default=''):
"""Get the built-in validation error message for when
the email is invalid in some way.
:param email: The proposed email (unicode).
:param default: The message to default to in case of no error.
:return: Validation error message.
"""
try:
_validate_email(email)
except AccountEmailInvalid as invalid_email_err:
return invalid_email_err.message
return default
def get_password_validation_error(password, username=None, default=''):
"""Get the built-in validation error message for when
the password is invalid in some way.
:param password: The proposed password (unicode).
:param username: The username associated with the user's account (unicode).
:param default: The message to default to in case of no error.
:return: Validation error message.
"""
try:
_validate_password(password, username)
except AccountPasswordInvalid as invalid_password_err:
return invalid_password_err.message
return default
def get_username_existence_validation_error(username, default=''):
"""Get the built-in validation error message for when
the username has an existence conflict.
:param username: The proposed username (unicode).
:param default: The message to default to in case of no error.
:return: Validation error message.
"""
try:
_validate_username_doesnt_exist(username)
except AccountUsernameAlreadyExists as username_exists_err:
return username_exists_err.message
return default
def get_email_existence_validation_error(email, default=''):
"""Get the built-in validation error message for when
the email has an existence conflict.
:param email: The proposed email (unicode).
:param default: The message to default to in case of no error.
:return: Validation error message.
"""
try:
_validate_email_doesnt_exist(email)
except AccountEmailAlreadyExists as email_exists_err:
return email_exists_err.message
return default
def _get_user_and_profile(username):
"""
Helper method to return the legacy user and profile objects based on username.
"""
try:
existing_user = User.objects.get(username=username)
except ObjectDoesNotExist:
raise UserNotFound()
existing_user_profile, _ = UserProfile.objects.get_or_create(user=existing_user)
return existing_user, existing_user_profile
def _validate_username(username): def _validate_username(username):
"""Validate the username. """Validate the username.
...@@ -432,33 +529,54 @@ def _validate_username(username): ...@@ -432,33 +529,54 @@ def _validate_username(username):
AccountUsernameInvalid AccountUsernameInvalid
""" """
if not isinstance(username, basestring): try:
raise AccountUsernameInvalid(u"Username must be a string") _validate_unicode(username)
_validate_type(username, basestring, USERNAME_BAD_TYPE_MSG)
if len(username) < USERNAME_MIN_LENGTH: _validate_length(
raise AccountUsernameInvalid( username, USERNAME_MIN_LENGTH, USERNAME_MAX_LENGTH, USERNAME_BAD_LENGTH_MSG.format(
u"Username '{username}' must be at least {min} characters long".format(
username=username,
min=USERNAME_MIN_LENGTH
)
)
if len(username) > USERNAME_MAX_LENGTH:
raise AccountUsernameInvalid(
u"Username '{username}' must be at most {max} characters long".format(
username=username, username=username,
min=USERNAME_MIN_LENGTH,
max=USERNAME_MAX_LENGTH max=USERNAME_MAX_LENGTH
) )
) )
try:
with override_language('en'): with override_language('en'):
# `validate_username` provides a proper localized message, however the API needs only the English # `validate_username` provides a proper localized message, however the API needs only the English
# message by convention. # message by convention.
student_forms.validate_username(username) student_forms.validate_username(username)
except ValidationError as error: except (UnicodeError, AccountDataBadType, AccountDataBadLength, ValidationError) as invalid_username_err:
raise AccountUsernameInvalid(error.message) raise AccountUsernameInvalid(invalid_username_err.message)
def _validate_email(email):
"""Validate the format of the email address.
Arguments:
email (unicode): The proposed email.
Returns:
None
Raises:
AccountEmailInvalid
"""
try:
_validate_unicode(email)
_validate_type(email, basestring, EMAIL_BAD_TYPE_MSG)
_validate_length(
email, EMAIL_MIN_LENGTH, EMAIL_MAX_LENGTH, EMAIL_BAD_LENGTH_MSG.format(
email=email,
min=EMAIL_MIN_LENGTH,
max=EMAIL_MAX_LENGTH
)
)
validate_email.message = EMAIL_INVALID_MSG.format(email=email)
validate_email(email)
except (UnicodeError, AccountDataBadType, AccountDataBadLength, ValidationError) as invalid_email_err:
raise AccountEmailInvalid(invalid_email_err.message)
def _validate_password(password, username):
def _validate_password(password, username=None):
"""Validate the format of the user's password. """Validate the format of the user's password.
Passwords cannot be the same as the username of the account, Passwords cannot be the same as the username of the account,
...@@ -475,62 +593,99 @@ def _validate_password(password, username): ...@@ -475,62 +593,99 @@ def _validate_password(password, username):
AccountPasswordInvalid AccountPasswordInvalid
""" """
if not isinstance(password, basestring): try:
raise AccountPasswordInvalid(u"Password must be a string") _validate_type(password, basestring, PASSWORD_BAD_TYPE_MSG)
_validate_length(
if len(password) < PASSWORD_MIN_LENGTH: password, PASSWORD_MIN_LENGTH, PASSWORD_MAX_LENGTH, PASSWORD_BAD_LENGTH_MSG.format(
raise AccountPasswordInvalid( min=PASSWORD_MIN_LENGTH,
u"Password must be at least {min} characters long".format(
min=PASSWORD_MIN_LENGTH
)
)
if len(password) > PASSWORD_MAX_LENGTH:
raise AccountPasswordInvalid(
u"Password must be at most {max} characters long".format(
max=PASSWORD_MAX_LENGTH max=PASSWORD_MAX_LENGTH
) )
) )
_validate_password_works_with_username(password, username)
except (AccountDataBadType, AccountDataBadLength) as invalid_password_err:
raise AccountPasswordInvalid(invalid_password_err.message)
def _validate_username_doesnt_exist(username):
"""Validate that the username is not associated with an existing user.
:param username: The proposed username (unicode).
:return: None
:raises: AccountUsernameAlreadyExists
"""
if username is not None and User.objects.filter(username=username).exists():
raise AccountUsernameAlreadyExists(_(USERNAME_CONFLICT_MSG).format(username=username))
def _validate_email_doesnt_exist(email):
"""Validate that the email is not associated with an existing user.
:param email: The proposed email (unicode).
:return: None
:raises: AccountEmailAlreadyExists
"""
if email is not None and User.objects.filter(email=email).exists():
raise AccountEmailAlreadyExists(_(EMAIL_CONFLICT_MSG).format(email_address=email))
def _validate_password_works_with_username(password, username=None):
"""Run validation checks on whether the password and username
go well together.
An example check is to see whether they are the same.
:param password: The proposed password (unicode).
:param username: The username associated with the user's account (unicode).
:return: None
:raises: AccountPasswordInvalid
"""
if password == username: if password == username:
raise AccountPasswordInvalid(u"Password cannot be the same as the username") raise AccountPasswordInvalid(PASSWORD_CANT_EQUAL_USERNAME_MSG)
def _validate_email(email): def _validate_type(data, type, err):
"""Validate the format of the email address. """Checks whether the input data is of type. If not,
throws a generic error message.
Arguments: :param data: The data to check.
email (unicode): The proposed email. :param type: The type to check against.
:param err: The error message to throw back if data is not of type.
:return: None
:raises: AccountDataBadType
Returns: """
None if not isinstance(data, type):
raise AccountDataBadType(err)
Raises:
AccountEmailInvalid def _validate_length(data, min, max, err):
"""Validate that the data's length is less than or equal to max,
and greater than or equal to min.
:param data: The data to do the test on.
:param min: The minimum allowed length.
:param max: The maximum allowed length.
:return: None
:raises: AccountDataBadLength
""" """
if not isinstance(email, basestring): if len(data) < min or len(data) > max:
raise AccountEmailInvalid(u"Email must be a string") raise AccountDataBadLength(err)
if len(email) < EMAIL_MIN_LENGTH:
raise AccountEmailInvalid(
u"Email '{email}' must be at least {min} characters long".format(
email=email,
min=EMAIL_MIN_LENGTH
)
)
if len(email) > EMAIL_MAX_LENGTH: def _validate_unicode(data, err=u"Input not valid unicode"):
raise AccountEmailInvalid( """Checks whether the input data is valid unicode or not.
u"Email '{email}' must be at most {max} characters long".format(
email=email,
max=EMAIL_MAX_LENGTH
)
)
:param data: The data to check for unicode validity.
:param err: The error message to throw back if unicode is invalid.
:return: None
:raises: UnicodeError
"""
try: try:
validate_email(email) if not isinstance(data, str) and not isinstance(data, unicode):
except ValidationError: raise UnicodeError
raise AccountEmailInvalid( # In some cases we pass the above, but it's still inappropriate utf-8.
u"Email '{email}' format is not valid".format(email=email) str(data)
) except UnicodeError:
raise UnicodeError(err)
...@@ -3,6 +3,7 @@ ...@@ -3,6 +3,7 @@
Unit tests for behavior that is specific to the api methods (vs. the view methods). Unit tests for behavior that is specific to the api methods (vs. the view methods).
Most of the functionality is covered in test_views.py. Most of the functionality is covered in test_views.py.
""" """
import re import re
import ddt import ddt
from dateutil.parser import parse as parse_datetime from dateutil.parser import parse as parse_datetime
...@@ -17,17 +18,29 @@ from django.conf import settings ...@@ -17,17 +18,29 @@ from django.conf import settings
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.core import mail from django.core import mail
from django.test.client import RequestFactory from django.test.client import RequestFactory
from openedx.core.djangoapps.user_api.accounts import (
USERNAME_MAX_LENGTH,
PRIVATE_VISIBILITY
)
from openedx.core.djangoapps.user_api.accounts.api import (
get_account_settings,
update_account_settings,
create_account,
activate_account,
request_password_change
)
from openedx.core.djangoapps.user_api.errors import (
UserNotFound, UserNotAuthorized,
AccountUpdateError, AccountValidationError, AccountUserAlreadyExists,
AccountUsernameInvalid, AccountEmailInvalid, AccountPasswordInvalid,
AccountRequestError
)
from openedx.core.djangoapps.user_api.accounts.tests.testutils import (
INVALID_EMAILS, INVALID_PASSWORDS, INVALID_USERNAMES
)
from openedx.core.djangolib.testing.utils import skip_unless_lms from openedx.core.djangolib.testing.utils import skip_unless_lms
from student.models import PendingEmailChange from student.models import PendingEmailChange
from student.tests.tests import UserSettingsEventTestMixin from student.tests.tests import UserSettingsEventTestMixin
from ...errors import (
UserNotFound, UserNotAuthorized, AccountUpdateError, AccountValidationError,
AccountUserAlreadyExists, AccountUsernameInvalid, AccountEmailInvalid, AccountPasswordInvalid, AccountRequestError
)
from ..api import (
get_account_settings, update_account_settings, create_account, activate_account, request_password_change
)
from .. import USERNAME_MAX_LENGTH, EMAIL_MAX_LENGTH, PASSWORD_MAX_LENGTH, PRIVATE_VISIBILITY
def mock_render_to_string(template_name, context): def mock_render_to_string(template_name, context):
...@@ -310,40 +323,6 @@ class AccountCreationActivationAndPasswordChangeTest(TestCase): ...@@ -310,40 +323,6 @@ class AccountCreationActivationAndPasswordChangeTest(TestCase):
ORIG_HOST = 'example.com' ORIG_HOST = 'example.com'
IS_SECURE = False IS_SECURE = False
INVALID_USERNAMES = [
None,
u'',
u'a',
u'a' * (USERNAME_MAX_LENGTH + 1),
u'invalid_symbol_@',
u'invalid-unicode_fŕáńḱ',
]
INVALID_EMAILS = [
None,
u'',
u'a',
'no_domain',
'no+domain',
'@',
'@domain.com',
'test@no_extension',
u'fŕáńḱ@example.com',
# Long email -- subtract the length of the @domain
# except for one character (so we exceed the max length limit)
u'{user}@example.com'.format(
user=(u'e' * (EMAIL_MAX_LENGTH - 11))
)
]
INVALID_PASSWORDS = [
None,
u'',
u'a',
u'a' * (PASSWORD_MAX_LENGTH + 1)
]
@skip_unless_lms @skip_unless_lms
def test_activate_account(self): def test_activate_account(self):
# Create the account, which is initially inactive # Create the account, which is initially inactive
......
# -*- coding: utf-8 -*-
"""
Utility functions, constants, etc. for testing.
"""
from openedx.core.djangoapps.user_api.accounts import (
USERNAME_MIN_LENGTH, USERNAME_MAX_LENGTH,
EMAIL_MAX_LENGTH,
PASSWORD_MIN_LENGTH, PASSWORD_MAX_LENGTH
)
INVALID_USERNAMES_ASCII = [
'$invalid-ascii$',
'invalid-fŕáńḱ',
'@invalid-ascii@'
]
INVALID_USERNAMES_UNICODE = [
u'invalid-unicode_fŕáńḱ',
]
INVALID_USERNAMES = [
None,
u'',
u'a',
u'a' * (USERNAME_MAX_LENGTH + 1),
] + INVALID_USERNAMES_ASCII + INVALID_USERNAMES_UNICODE
INVALID_EMAILS = [
None,
u'',
u'a',
'no_domain',
'no+domain',
'@',
'@domain.com',
'test@no_extension',
u'fŕáńḱ@example.com',
# Long email -- subtract the length of the @domain
# except for one character (so we exceed the max length limit)
u'{user}@example.com'.format(
user=(u'e' * (EMAIL_MAX_LENGTH - 11))
)
]
INVALID_PASSWORDS = [
None,
u'',
u'a',
u'a' * (PASSWORD_MAX_LENGTH + 1)
]
VALID_USERNAMES = [
u'username',
u'a' * USERNAME_MIN_LENGTH,
u'a' * USERNAME_MAX_LENGTH,
u'-' * USERNAME_MIN_LENGTH,
u'-' * USERNAME_MAX_LENGTH,
u'_username_',
u'-username-',
u'-_username_-'
]
VALID_EMAILS = [
'has@domain.com'
]
VALID_PASSWORDS = [
u'password', # :)
u'a' * PASSWORD_MIN_LENGTH,
u'a' * PASSWORD_MAX_LENGTH
]
...@@ -33,6 +33,16 @@ class AccountUserAlreadyExists(AccountRequestError): ...@@ -33,6 +33,16 @@ class AccountUserAlreadyExists(AccountRequestError):
pass pass
class AccountUsernameAlreadyExists(AccountRequestError):
"""User with the same username already exists. """
pass
class AccountEmailAlreadyExists(AccountRequestError):
"""User with the same email already exists. """
pass
class AccountUsernameInvalid(AccountRequestError): class AccountUsernameInvalid(AccountRequestError):
"""The requested username is not in a valid format. """ """The requested username is not in a valid format. """
pass pass
...@@ -48,6 +58,16 @@ class AccountPasswordInvalid(AccountRequestError): ...@@ -48,6 +58,16 @@ class AccountPasswordInvalid(AccountRequestError):
pass pass
class AccountDataBadLength(AccountRequestError):
"""The requested account data is either too short or too long. """
pass
class AccountDataBadType(AccountRequestError):
"""The requested account data is of the wrong type. """
pass
class AccountUpdateError(AccountRequestError): class AccountUpdateError(AccountRequestError):
""" """
An update to the account failed. More detailed information is present in developer_message, An update to the account failed. More detailed information is present in developer_message,
......
...@@ -31,7 +31,6 @@ from third_party_auth.tests.utils import ( ...@@ -31,7 +31,6 @@ from third_party_auth.tests.utils import (
from .test_helpers import TestCaseForm from .test_helpers import TestCaseForm
from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory from xmodule.modulestore.tests.factories import CourseFactory
from ..helpers import FormDescription
from ..accounts import ( from ..accounts import (
NAME_MAX_LENGTH, EMAIL_MIN_LENGTH, EMAIL_MAX_LENGTH, PASSWORD_MIN_LENGTH, PASSWORD_MAX_LENGTH, NAME_MAX_LENGTH, EMAIL_MIN_LENGTH, EMAIL_MAX_LENGTH, PASSWORD_MIN_LENGTH, PASSWORD_MAX_LENGTH,
USERNAME_MIN_LENGTH, USERNAME_MAX_LENGTH USERNAME_MIN_LENGTH, USERNAME_MAX_LENGTH
......
...@@ -9,6 +9,7 @@ from ..profile_images.views import ProfileImageView ...@@ -9,6 +9,7 @@ from ..profile_images.views import ProfileImageView
from .accounts.views import AccountDeactivationView, AccountViewSet from .accounts.views import AccountDeactivationView, AccountViewSet
from .preferences.views import PreferencesDetailView, PreferencesView from .preferences.views import PreferencesDetailView, PreferencesView
from .verification_api.views import PhotoVerificationStatusView from .verification_api.views import PhotoVerificationStatusView
from .validation.views import RegistrationValidationView
ME = AccountViewSet.as_view({ ME = AccountViewSet.as_view({
'get': 'get', 'get': 'get',
...@@ -25,9 +26,21 @@ ACCOUNT_DETAIL = AccountViewSet.as_view({ ...@@ -25,9 +26,21 @@ ACCOUNT_DETAIL = AccountViewSet.as_view({
urlpatterns = patterns( urlpatterns = patterns(
'', '',
url(r'^v1/me$', ME, name='own_username_api'), url(
url(r'^v1/accounts/{}$'.format(settings.USERNAME_PATTERN), ACCOUNT_DETAIL, name='accounts_api'), r'^v1/me$',
url(r'^v1/accounts$', ACCOUNT_LIST, name='accounts_detail_api'), ME,
name='own_username_api'
),
url(
r'^v1/accounts$',
ACCOUNT_LIST,
name='accounts_detail_api'
),
url(
r'^v1/accounts/{}$'.format(settings.USERNAME_PATTERN),
ACCOUNT_DETAIL,
name='accounts_api'
),
url( url(
r'^v1/accounts/{}/image$'.format(settings.USERNAME_PATTERN), r'^v1/accounts/{}/image$'.format(settings.USERNAME_PATTERN),
ProfileImageView.as_view(), ProfileImageView.as_view(),
...@@ -44,6 +57,11 @@ urlpatterns = patterns( ...@@ -44,6 +57,11 @@ urlpatterns = patterns(
name='verification_status' name='verification_status'
), ),
url( url(
r'^v1/validation/registration$',
RegistrationValidationView.as_view(),
name='registration_validation'
),
url(
r'^v1/preferences/{}$'.format(settings.USERNAME_PATTERN), r'^v1/preferences/{}$'.format(settings.USERNAME_PATTERN),
PreferencesView.as_view(), PreferencesView.as_view(),
name='preferences_api' name='preferences_api'
......
# -*- coding: utf-8 -*-
"""
Tests for an API endpoint for client-side user data validation.
"""
import unittest
import ddt
from django.conf import settings
from django.contrib.auth.models import User
from django.core.urlresolvers import reverse
from openedx.core.djangoapps.user_api.accounts import (
EMAIL_BAD_LENGTH_MSG, EMAIL_INVALID_MSG,
EMAIL_CONFLICT_MSG, EMAIL_MAX_LENGTH, EMAIL_MIN_LENGTH,
PASSWORD_BAD_LENGTH_MSG, PASSWORD_CANT_EQUAL_USERNAME_MSG,
PASSWORD_MAX_LENGTH, PASSWORD_MIN_LENGTH,
USERNAME_BAD_LENGTH_MSG, USERNAME_INVALID_CHARS_ASCII, USERNAME_INVALID_CHARS_UNICODE,
USERNAME_CONFLICT_MSG, USERNAME_MAX_LENGTH, USERNAME_MIN_LENGTH
)
from openedx.core.djangoapps.user_api.accounts.tests.testutils import (
VALID_EMAILS, VALID_PASSWORDS, VALID_USERNAMES,
INVALID_EMAILS, INVALID_PASSWORDS, INVALID_USERNAMES,
INVALID_USERNAMES_ASCII, INVALID_USERNAMES_UNICODE
)
from openedx.core.lib.api import test_utils
@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 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(
['email', (email for email in VALID_EMAILS)],
['password', (password for password in VALID_PASSWORDS)],
['username', (username for username in VALID_USERNAMES)]
)
@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.
['email', (email for email in INVALID_EMAILS[1:])],
['password', (password for password in INVALID_PASSWORDS[1:])],
['username', (username for username in INVALID_USERNAMES[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.assertNotEqual(
self.get_validation_decision({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": USERNAME_CONFLICT_MSG.format(username=user.username) if username == user.username else '',
"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_less_than_min_length_validation_decision(self, email):
self.assertValidationDecision(
{'email': email},
{'email': EMAIL_BAD_LENGTH_MSG.format(email=email, min=EMAIL_MIN_LENGTH, max=EMAIL_MAX_LENGTH)}
)
def test_email_generically_invalid_validation_decision(self):
email = 'email'
self.assertValidationDecision(
{'email': email},
{'email': EMAIL_INVALID_MSG.format(email=email)}
)
@ddt.data(
'u' * (USERNAME_MIN_LENGTH - 1),
'u' * (USERNAME_MAX_LENGTH + 1)
)
def test_username_less_than_min_length_validation_decision(self, username):
self.assertValidationDecision(
{'username': username},
{
'username': USERNAME_BAD_LENGTH_MSG.format(
username=username,
min=USERNAME_MIN_LENGTH,
max=USERNAME_MAX_LENGTH
)
}
)
@unittest.skipUnless(settings.FEATURES.get("ENABLE_UNICODE_USERNAME"), "Unicode usernames disabled.")
@ddt.data(*INVALID_USERNAMES_UNICODE)
@ddt.unpack
def test_username_invalid_unicode_validation_decision(self, username):
self.assertValidationDecision(
{'username': username},
{'username': USERNAME_INVALID_CHARS_UNICODE}
)
@unittest.skipIf(settings.FEATURES.get("ENABLE_UNICODE_USERNAME"), "Unicode usernames enabled.")
@ddt.data(*INVALID_USERNAMES_ASCII)
@ddt.unpack
def test_username_invalid_ascii_validation_decision(self, username):
self.assertValidationDecision(
{'username': username},
{"username": USERNAME_INVALID_CHARS_ASCII}
)
@ddt.data(
'p' * (PASSWORD_MIN_LENGTH - 1),
'p' * (PASSWORD_MAX_LENGTH + 1)
)
def test_password_less_than_min_length_validation_decision(self, password):
self.assertValidationDecision(
{'password': password},
{"password": PASSWORD_BAD_LENGTH_MSG.format(min=PASSWORD_MIN_LENGTH, max=PASSWORD_MAX_LENGTH)}
)
def test_password_equals_username_validation_decision(self):
self.assertValidationDecision(
{"username": "somephrase", "password": "somephrase"},
{"username": "", "password": PASSWORD_CANT_EQUAL_USERNAME_MSG}
)
# -*- coding: utf-8 -*-
"""
An API for client-side validation of (potential) user data.
"""
from rest_framework.response import Response
from rest_framework.views import APIView
from openedx.core.djangoapps.user_api.accounts.api import (
get_email_validation_error,
get_email_existence_validation_error,
get_password_validation_error,
get_username_validation_error,
get_username_existence_validation_error
)
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**
"username":
A handler to check the validity of usernames.
"email":
A handler to check the validity of emails.
"password":
A handler to check the validity of passwords; a compatibility
decision with the username is made if it exists in the input.
"""
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)
# Existing usernames are already valid, so we prefer that error.
return username_exists_error or invalid_username_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)
# Existing emails are already valid, so we prefer that error.
return email_exists_error or invalid_email_error
def password_handler(self, request):
username = request.data.get('username') or None
password = request.data.get('password')
return get_password_validation_error(password, username)
validation_handlers = {
"username": username_handler,
"email": email_handler,
"password": password_handler,
}
def post(self, request):
"""
POST /api/user/v1/validation/registration/
Expects request of the form
>>> {
>>> "username": "mslm",
>>> "email": "mslm@gmail.com",
>>> "password": "password123"
>>> }
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})
...@@ -32,13 +32,12 @@ from student.views import create_account_with_params, AccountValidationError ...@@ -32,13 +32,12 @@ from student.views import create_account_with_params, AccountValidationError
from util.json_request import JsonResponse from util.json_request import JsonResponse
from .accounts import ( from .accounts import (
EMAIL_MAX_LENGTH, EMAIL_MAX_LENGTH, EMAIL_MIN_LENGTH,
EMAIL_MIN_LENGTH,
NAME_MAX_LENGTH, NAME_MAX_LENGTH,
PASSWORD_MAX_LENGTH, PASSWORD_MAX_LENGTH, PASSWORD_MIN_LENGTH,
PASSWORD_MIN_LENGTH, USERNAME_MAX_LENGTH, USERNAME_MIN_LENGTH,
USERNAME_MAX_LENGTH, EMAIL_CONFLICT_MSG,
USERNAME_MIN_LENGTH USERNAME_CONFLICT_MSG
) )
from .accounts.api import check_account_exists from .accounts.api import check_account_exists
from .helpers import FormDescription, require_post_params, shim_student_view from .helpers import FormDescription, require_post_params, shim_student_view
...@@ -340,18 +339,8 @@ class RegistrationView(APIView): ...@@ -340,18 +339,8 @@ class RegistrationView(APIView):
conflicts = check_account_exists(email=email, username=username) conflicts = check_account_exists(email=email, username=username)
if conflicts: if conflicts:
conflict_messages = { conflict_messages = {
"email": _( "email": EMAIL_CONFLICT_MSG.format(email_address=email),
# Translators: This message is shown to users who attempt to create a new "username": USERNAME_CONFLICT_MSG.format(username=username),
# account using an email address associated with an existing account.
u"It looks like {email_address} belongs to an existing account. "
u"Try again with a different email address."
).format(email_address=email),
"username": _(
# Translators: This message is shown to users who attempt to create a new
# account using a username associated with an existing account.
u"It looks like {username} belongs to an existing account. "
u"Try again with a different username."
).format(username=username),
} }
errors = { errors = {
field: [{"user_message": conflict_messages[field]}] field: [{"user_message": conflict_messages[field]}]
......
...@@ -28,7 +28,7 @@ class ApiTestCase(TestCase): ...@@ -28,7 +28,7 @@ class ApiTestCase(TestCase):
return getattr(self.client, method)(*args, HTTP_X_EDX_API_KEY=TEST_API_KEY, **kwargs) return getattr(self.client, method)(*args, HTTP_X_EDX_API_KEY=TEST_API_KEY, **kwargs)
def get_json(self, *args, **kwargs): def get_json(self, *args, **kwargs):
"""Make a request with the given args and return the parsed JSON repsonse""" """Make a request with the given args and return the parsed JSON response"""
resp = self.request_with_auth("get", *args, **kwargs) resp = self.request_with_auth("get", *args, **kwargs)
self.assertHttpOK(resp) self.assertHttpOK(resp)
self.assertTrue(resp["Content-Type"].startswith("application/json")) self.assertTrue(resp["Content-Type"].startswith("application/json"))
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment