Files
edx-platform/common/djangoapps/user_api/api/account.py
Renzo Lucioni ff587c5cbb Add password reset request handling to the account page
The next step in the password reset process (confirmation) continues to be handled by student.views.password_reset_confirm_wrapper, a custom wrapper around Django's password reset confirmation view.
2014-10-18 00:54:38 -04:00

457 lines
14 KiB
Python

"""Python API for user accounts.
Account information includes a student's username, password, and email
address, but does NOT include user profile information (i.e., demographic
information and preferences).
"""
from django.conf import settings
from django.db import transaction, IntegrityError
from django.core.validators import validate_email, validate_slug, ValidationError
from django.contrib.auth.forms import PasswordResetForm
from user_api.models import User, UserProfile, Registration, PendingEmailChange
from user_api.helpers import intercept_errors
USERNAME_MIN_LENGTH = 2
USERNAME_MAX_LENGTH = 30
EMAIL_MIN_LENGTH = 3
EMAIL_MAX_LENGTH = 254
PASSWORD_MIN_LENGTH = 2
PASSWORD_MAX_LENGTH = 75
class AccountRequestError(Exception):
"""There was a problem with the request to the account API. """
pass
class AccountInternalError(Exception):
"""An internal error occurred in the account API. """
pass
class AccountUserAlreadyExists(AccountRequestError):
"""User with the same username and/or email already exists. """
pass
class AccountUsernameAlreadyExists(AccountUserAlreadyExists):
"""An account already exists with the requested username. """
pass
class AccountEmailAlreadyExists(AccountUserAlreadyExists):
"""An account already exists with the requested email. """
pass
class AccountUsernameInvalid(AccountRequestError):
"""The requested username is not in a valid format. """
pass
class AccountEmailInvalid(AccountRequestError):
"""The requested email is not in a valid format. """
pass
class AccountPasswordInvalid(AccountRequestError):
"""The requested password is not in a valid format. """
pass
class AccountUserNotFound(AccountRequestError):
"""The requested user does not exist. """
pass
class AccountNotAuthorized(AccountRequestError):
"""The user is not authorized to perform the requested action. """
pass
@intercept_errors(AccountInternalError, ignore_errors=[AccountRequestError])
@transaction.commit_on_success
def create_account(username, password, email):
"""Create a new user account.
This will implicitly create an empty profile for the user.
WARNING: This function does NOT yet implement all the features
in `student/views.py`. Until it does, please use this method
ONLY for tests of the account API, not in production code.
In particular, these are currently missing:
* 3rd party auth
* External auth (shibboleth)
* Complex password policies (ENFORCE_PASSWORD_POLICY)
In addition, we assume that some functionality is handled
at higher layers:
* Analytics events
* Activation email
* Terms of service / honor code checking
* Recording demographic info (use profile API)
* Auto-enrollment in courses (if invited via instructor dash)
Args:
username (unicode): The username for the new account.
password (unicode): The user's password.
email (unicode): The email address associated with the account.
Returns:
unicode: an activation key for the account.
Raises:
AccountUserAlreadyExists
AccountUsernameInvalid
AccountEmailInvalid
AccountPasswordInvalid
"""
# Validate the username, password, and email
# This will raise an exception if any of these are not in a valid format.
_validate_username(username)
_validate_password(password, username)
_validate_email(email)
# Create the user account, setting them to "inactive" until they activate their account.
user = User(username=username, email=email, is_active=False)
user.set_password(password)
try:
user.save()
except IntegrityError:
raise AccountUserAlreadyExists
# Create a registration to track the activation process
# This implicitly saves the registration.
registration = Registration()
registration.register(user)
# Create an empty user profile with default values
UserProfile(user=user).save()
# Return the activation key, which the caller should send to the user
return registration.activation_key
@intercept_errors(AccountInternalError, ignore_errors=[AccountRequestError])
def account_info(username):
"""Retrieve information about a user's account.
Arguments:
username (unicode): The username associated with the account.
Returns:
dict: User's account information, if the user was found.
None: The user does not exist.
"""
try:
user = User.objects.get(username=username)
except User.DoesNotExist:
return None
else:
return {
u'username': username,
u'email': user.email,
u'is_active': user.is_active,
}
@intercept_errors(AccountInternalError, ignore_errors=[AccountRequestError])
def activate_account(activation_key):
"""Activate a user's account.
Args:
activation_key (unicode): The activation key the user received via email.
Returns:
None
Raises:
AccountNotAuthorized
"""
try:
registration = Registration.objects.get(activation_key=activation_key)
except Registration.DoesNotExist:
raise AccountNotAuthorized
else:
# This implicitly saves the registration
registration.activate()
@intercept_errors(AccountInternalError, ignore_errors=[AccountRequestError])
def request_email_change(username, new_email, password):
"""Request an email change.
Users must confirm the change before we update their information.
Args:
username (unicode): The username associated with the account.
new_email (unicode): The user's new email address.
password (unicode): The password the user entered to authorize the change.
Returns:
unicode: an activation key for the account.
Raises:
AccountUserNotFound
AccountEmailAlreadyExists
AccountEmailInvalid
AccountNotAuthorized
"""
try:
user = User.objects.get(username=username)
except User.DoesNotExist:
raise AccountUserNotFound
# Check the user's credentials
if not user.check_password(password):
raise AccountNotAuthorized
# Validate the email, raising an exception if it is not in the correct format
_validate_email(new_email)
# Verify that no active account has taken the email in between
# the request and the activation.
# We'll check again before confirming and persisting the change,
# but if the email is already taken by an active account, we should
# let the user know as soon as possible.
if User.objects.filter(email=new_email, is_active=True).exists():
raise AccountEmailAlreadyExists
try:
pending_change = PendingEmailChange.objects.get(user=user)
except PendingEmailChange.DoesNotExist:
pending_change = PendingEmailChange(user=user)
# Update the change (re-using the same record if it already exists)
# This will generate a new activation key and save the record.
return pending_change.request_change(new_email)
@intercept_errors(AccountInternalError, ignore_errors=[AccountRequestError])
@transaction.commit_on_success
def confirm_email_change(activation_key):
"""Confirm an email change.
Users can confirm the change by providing an activation key
they received via email.
Args:
activation_key (unicode): The activation key the user received
when he/she requested the email change.
Returns:
Tuple: (old_email, new_email)
Raises:
AccountNotAuthorized: The activation code is invalid.
AccountEmailAlreadyExists: Someone else has already taken the email address.
AccountInternalError
"""
try:
# Activation key has a uniqueness constraint, so we're guaranteed to get
# at most one pending change.
pending_change = PendingEmailChange.objects.select_related('user').get(
activation_key=activation_key
)
except PendingEmailChange.DoesNotExist:
# If there are no changes, then the activation key is invalid
raise AccountNotAuthorized
else:
old_email = pending_change.user.email
new_email = pending_change.new_email
# Verify that no one else has taken the email in between
# the request and the activation.
# In our production database, email has a uniqueness constraint,
# so there is no danger of a race condition here.
if User.objects.filter(email=new_email).exists():
raise AccountEmailAlreadyExists
# Update the email history (in the user profile)
try:
profile = UserProfile.objects.get(user=pending_change.user)
except UserProfile.DoesNotExist:
raise AccountInternalError(
"No profile exists for the user '{username}'".format(
username=pending_change.user.username
)
)
else:
profile.update_email(new_email)
# Delete the pending change, so that the activation code
# will be single-use
pending_change.delete()
# Return the old and new email
# This allows the caller of the function to notify users at both
# the new and old email, which is necessary for security reasons.
return (old_email, new_email)
@intercept_errors(AccountInternalError, ignore_errors=[AccountRequestError])
def request_password_change(email, orig_host, is_secure):
"""Email a single-use link for performing a password reset.
Users must confirm the password change before we update their information.
Args:
email (string): An email address
orig_host (string): An originating host, extracted from a request with get_host
is_secure (Boolean): Whether the request was made with HTTPS
Returns:
None
Raises:
AccountUserNotFound
AccountRequestError
"""
# Binding data to a form requires that the data be passed as a dictionary
# to the Form class constructor.
form = PasswordResetForm({'email': email})
# Validate that an active user exists with the given email address.
if form.is_valid():
# Generate a single-use link for performing a password reset
# and email it to the user.
form.save(
from_email=settings.DEFAULT_FROM_EMAIL,
domain_override=orig_host,
use_https=is_secure
)
else:
# No active user with the provided email address exists.
raise AccountUserNotFound
def _validate_username(username):
"""Validate the username.
Arguments:
username (unicode): The proposed username.
Returns:
None
Raises:
AccountUsernameInvalid
"""
if not isinstance(username, basestring):
raise AccountUsernameInvalid(u"Username must be a string")
if len(username) < USERNAME_MIN_LENGTH:
raise AccountUsernameInvalid(
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,
max=USERNAME_MAX_LENGTH
)
)
try:
validate_slug(username)
except ValidationError:
raise AccountUsernameInvalid(
u"Username '{username}' must contain only A-Z, a-z, 0-9, -, or _ characters"
)
def _validate_password(password, username):
"""Validate the format of the user's password.
Passwords cannot be the same as the username of the account,
so we take `username` as an argument.
Arguments:
password (unicode): The proposed password.
username (unicode): The username associated with the user's account.
Returns:
None
Raises:
AccountPasswordInvalid
"""
if not isinstance(password, basestring):
raise AccountPasswordInvalid(u"Password must be a string")
if len(password) < PASSWORD_MIN_LENGTH:
raise AccountPasswordInvalid(
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
)
)
if password == username:
raise AccountPasswordInvalid(u"Password cannot be the same as the username")
def _validate_email(email):
"""Validate the format of the email address.
Arguments:
email (unicode): The proposed email.
Returns:
None
Raises:
AccountEmailInvalid
"""
if not isinstance(email, basestring):
raise AccountEmailInvalid(u"Email must be a string")
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:
raise AccountEmailInvalid(
u"Email '{email}' must be at most {max} characters long".format(
email=email,
max=EMAIL_MAX_LENGTH
)
)
try:
validate_email(email)
except ValidationError:
raise AccountEmailInvalid(
u"Email '{email}' format is not valid".format(email=email)
)