Skip to content

Commit

Permalink
Initial refactoring of activation to require POST to activate.
Browse files Browse the repository at this point in the history
  • Loading branch information
ubernostrum committed Oct 29, 2024
1 parent 90ba1d8 commit e3bb03e
Show file tree
Hide file tree
Showing 15 changed files with 456 additions and 388 deletions.
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -131,3 +131,7 @@ dmypy.json

# Cython debug symbols
cython_debug/

# IDEs.
.idea/
.vscode/
125 changes: 2 additions & 123 deletions docs/activation-workflow.rst
Original file line number Diff line number Diff line change
Expand Up @@ -64,130 +64,9 @@ specified below), and their context variables, see :ref:`the quick start guide
<default-templates>`.


.. class:: RegistrationView
.. autoclass:: RegistrationView

A subclass of :class:`django_registration.views.RegistrationView`
implementing the signup portion of this workflow.

Important customization points unique to this class are:

.. method:: create_inactive_user(form)

Creates and returns an inactive user account, and calls
:meth:`send_activation_email()` to send the email with the activation
key. The argument ``form`` is a valid registration form instance passed
from :meth:`~django_registration.views.RegistrationView.register()`.

:param django_registration.forms.RegistrationForm form: The registration form.
:rtype: django.contrib.auth.models.AbstractUser

.. method:: get_activation_key(user)

Given an instance of the user model, generates and returns an activation
key (a string) for that user account.

:param django.contrib.auth.models.AbstractUser user: The new user account.
:rtype: str

.. method:: get_email_context(activation_key)

Returns a dictionary of values to be used as template context when
generating the activation email.

:param str activation_key: The activation key for the new user account.
:rtype: dict

.. method:: send_activation_email(user)

Given an inactive user account, generates and sends the activation email
for that account.

:param django.contrib.auth.models.AbstractUser user: The new user account.
:rtype: None

.. attribute:: email_body_template

A string specifying the template to use for the body of the activation
email. Default is ``"django_registration/activation_email_body.txt"``.

.. attribute:: email_subject_template

A string specifying the template to use for the subject of the activation
email. Default is
``"django_registration/activation_email_subject.txt"``. Note that, to avoid
`header-injection vulnerabilities
<https://en.wikipedia.org/wiki/Email_injection>`_, the result of
rendering this template will be forced into a single line of text,
stripping newline characters.

.. class:: ActivationView

A subclass of :class:`django_registration.views.ActivationView` implementing
the activation portion of this workflow.

Errors in activating the user account will raise
:exc:`~django_registration.exceptions.ActivationError`, with one of the
following values for the exception's ``code``:

``"already_activated"``
Indicates the account has already been activated.

``"bad_username"``
Indicates the username decoded from the activation key is invalid (does
not correspond to any user account).

``"expired"``
Indicates the account/activation key has expired.

``"invalid_key"``
Generic indicator that the activation key was invalid.

Important customization points unique to this class are:

.. method:: get_user(username)

Given a username (determined by the activation key), looks up and returns
the corresponding instance of the user model. If no such account exists,
raises :exc:`~django_registration.exceptions.ActivationError` as
described above. In the base implementation, checks the
:attr:`~django.contrib.auth.models.User.is_active` field to avoid
re-activating already-active accounts, and raises
:exc:`~django_registration.exceptions.ActivationError` with code
``already_activated`` to indicate this case.

:param str username: The username of the new user account.
:rtype: django.contrib.auth.models.AbstractUser
:raises django_registration.exceptions.ActivationError: if no
matching inactive user account exists.

.. method:: validate_key(activation_key)

Given the activation key, verifies that it carries a valid signature and
a timestamp no older than the number of days specified in the setting
``ACCOUNT_ACTIVATION_DAYS``, and returns the username from the activation
key. Raises :exc:`~django_registration.exceptions.ActivationError`, as
described above, if the activation key has an invalid signature or if the
timestamp is too old.

:param str activation_key: The activation key for the new user account.
:rtype: str
:raises django_registration.exceptions.ActivationError: if the
activation key has an invalid signature or is expired.

.. note:: **URL patterns for activation**

Although the actual value used in the activation key is the new user
account's username, the URL pattern for :class:`~views.ActivationView`
does not need to match all possible legal characters in a username. The
activation key that will be sent to the user (and thus matched in the
URL) is produced by :func:`django.core.signing.dumps()`, which
base64-encodes its output. Thus, the only characters this pattern needs
to match are those from `the URL-safe base64 alphabet
<http://tools.ietf.org/html/rfc4648#section-5>`_, plus the colon ("``:``")
which is used as a separator.

The default URL pattern for the activation view in
``django_registration.backends.activation.urls`` handles this for you.
.. autoclass:: ActivationView


How it works
Expand Down
1 change: 1 addition & 0 deletions docs/spelling_wordlist.txt
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ parsers
paypal
pаypаl
pre
querystring
regex
registrationview
runtime
Expand Down
140 changes: 5 additions & 135 deletions docs/views.rst
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@
Base view classes
=================

In order to allow the utmost flexibility in customizing and supporting
different workflows, django-registration makes use of Django's support for
`class-based views
In order to allow flexibility in customizing and supporting different
workflows, django-registration makes use of Django's support for `class-based
views
<https://docs.djangoproject.com/en/stable/topics/class-based-views/>`_. Included
in django-registration are two base classes which can be subclassed to
implement many types of registration workflows.
Expand All @@ -17,137 +17,7 @@ customization points specific to those subclasses. The following reference
covers useful attributes and methods of the base classes, for use in writing
your own custom registration workflows.

.. class:: RegistrationView
.. autoclass:: RegistrationView

A subclass of Django's :class:`~django.views.generic.edit.FormView` which
provides the infrastructure for supporting user registration.

Standard attributes and methods of
:class:`~django.views.generic.edit.FormView` can be overridden to control
behavior as described in Django's documentation, with the exception of
:meth:`get_success_url`, which must use the signature documented below.

When writing your own subclass, one method is required:

.. method:: register(form)

Implement your registration logic here. ``form`` will be the
(already-validated) form filled out by the user during the registration
process (i.e., a valid instance of
:class:`~django_registration.forms.RegistrationForm` or a subclass of
it).

This method should return the newly-registered user instance, and should
send the signal :data:`django_registration.signals.user_registered`. Note
that this is not automatically done for you when writing your own custom
subclass, so you must send this signal manually.

:param django_registration.forms.RegistrationForm form: The registration form to use.
:rtype: django.contrib.auth.models.AbstractUser

Useful optional places to override or customize on subclasses are:

.. attribute:: disallowed_url

The URL to redirect to when registration is disallowed. Can be a
hard-coded string, the string resulting from calling Django's
:func:`~django.urls.reverse` helper, or the lazy object produced by
Django's :func:`~django.urls.reverse_lazy` helper. Default value is the
result of calling :func:`~django.urls.reverse_lazy` with the URL name
``'registration_disallowed'``.

.. attribute:: form_class

The form class to use for user registration. Can be overridden on a
per-request basis (see below). Should be the actual class object; by
default, this class is
:class:`django_registration.forms.RegistrationForm`.

.. attribute:: success_url

The URL to redirect to after successful registration. Can be a hard-coded
string, the string resulting from calling Django's
:func:`~django.urls.reverse` helper, or the lazy object produced by
Django's :func:`~django.urls.reverse_lazy` helper. Can be overridden on a
per-request basis (see below). Default value is :data:`None`; subclasses
must override and provide this.

.. attribute:: template_name

The template to use for user registration. Should be a string. Default
value is ``'django_registration/registration_form.html'``.

.. method:: get_form_class()

Select a form class to use on a per-request basis. If not overridden,
will use :attr:`~form_class`. Should be the actual class object.

:rtype: django_registration.forms.RegistrationForm

.. method:: get_success_url(user)

Return a URL to redirect to after successful registration, on a
per-request or per-user basis. If not overridden, will use
:attr:`~success_url`. Should return a value of the same type as
:attr:`success_url` (see above).

:param django.contrib.auth.models.AbstractUser user: The new user account.
:rtype: str

.. method:: registration_allowed()

Should indicate whether user registration is allowed, either in general
or for this specific request. Default value is the value of the setting
:data:`~django.conf.settings.REGISTRATION_OPEN`.

:rtype: bool


.. class:: ActivationView

A subclass of Django's :class:`~django.views.generic.base.TemplateView`
which provides support for a separate account-activation step, in workflows
which require that.

One method is required:

.. method:: activate(*args, **kwargs)

Implement your activation logic here. You are free to configure your URL
patterns to pass any set of positional or keyword arguments to
:class:`ActivationView`, and they will in turn be passed to this method.

This method should return the newly-activated user instance (if
activation was successful), or raise
:class:`~django_registration.exceptions.ActivationError` (if activation
was not successful).

:rtype: django.contrib.auth.models.AbstractUser
:raises django_registration.exceptions.ActivationError: if activation fails.

Useful places to override or customize on an
:class:`ActivationView` subclass are:

.. attribute:: success_url

The URL to redirect to after successful activation. Can be a hard-coded
string, the string resulting from calling Django's
:func:`~django.urls.reverse` helper, or the lazy object produced by
Django's :func:`~django.urls.reverse_lazy` helper. Can be overridden on a
per-request basis (see below). Default value is :data:`None`; subclasses
must override and provide this.

.. attribute:: template_name

The template to use after failed user activation. Should be a
string. Default value is ``'django_registration/activation_failed.html'``.

.. method:: get_success_url(user)

Return a URL to redirect to after successful activation, on a per-request
or per-user basis. If not overridden, will use
:attr:`~success_url`. Should return a value of the same type as
:attr:`success_url` (see above).

:param django.contrib.auth.models.AbstractUser user: The activated user account.
:rtype: str
.. autoclass:: ActivationView
4 changes: 4 additions & 0 deletions src/django_registration/backends/activation/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,7 @@
https://django-registration.readthedocs.io/
"""

from django.conf import settings

REGISTRATION_SALT = getattr(settings, "REGISTRATION_SALT", "registration")
55 changes: 55 additions & 0 deletions src/django_registration/backends/activation/forms.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
"""
Forms used by the two-step activation workflow.
"""

from django import forms
from django.conf import settings
from django.core import signing
from django.core.exceptions import ValidationError
from django.utils.translation import gettext_lazy as _

from . import REGISTRATION_SALT

# pylint: disable=raise-missing-from


class ActivationForm(forms.Form):
"""
Form for the activation step of the two-step activation workflow.
This form has one field, the (string) activation key, which should be an HMAC-signed
value containing the username of the account to activate.
"""

EXPIRED_MESSAGE = _("This account has expired.")
INVALID_KEY_MESSAGE = _("The activation key you provided is invalid.")

activation_key = forms.CharField(widget=forms.HiddenInput())

def clean_activation_key(self):
"""
Validate the signature of the activation key.
"""
activation_key = self.cleaned_data["activation_key"]
try:
username = signing.loads(
activation_key,
salt=REGISTRATION_SALT,
max_age=settings.ACCOUNT_ACTIVATION_DAYS * 86400,
)
# This is a bit of a hack. Whatever we return here is the value Django will
# insert into cleaned_data under the name of this field, and although
# initially it's the activation-key value we here replace it with the
# username value decoded from that key. This allows the rest of the
# processing chain to avoid the need to decode the activation key again, but
# relies on the fact that we only do this when we've fully verified that the
# activation key was valid -- if it's invalid, cleaned_data will continue to
# have the raw activation key.
return username
except signing.SignatureExpired:
raise ValidationError(self.EXPIRED_MESSAGE, code="expired")
except signing.BadSignature:
raise ValidationError(self.INVALID_KEY_MESSAGE, code="invalid_key")
2 changes: 1 addition & 1 deletion src/django_registration/backends/activation/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
name="django_registration_activation_complete",
),
path(
"activate/<str:activation_key>/",
"activate/",
views.ActivationView.as_view(),
name="django_registration_activate",
),
Expand Down
Loading

0 comments on commit e3bb03e

Please sign in to comment.