Source code for django_otp.forms
from django import forms
from django.contrib.auth.forms import AuthenticationForm
from django.db import transaction
from django.utils.translation import gettext_lazy as _
from django.utils.translation import ngettext_lazy
from . import devices_for_user, match_token
from .models import Device, VerifyNotAllowed
[docs]
class OTPAuthenticationFormMixin:
"""
Shared functionality for
:class:`~django.contrib.auth.forms.AuthenticationForm` subclasses that wish
to handle OTP tokens. Subclasses must do the following in order to use
this:
#. Define three additional form fields::
otp_device = forms.CharField(required=False, widget=forms.Select)
otp_token = forms.CharField(required=False, widget=forms.TextInput(attrs={'autocomplete': 'off'}))
otp_challenge = forms.CharField(required=False)
- ``otp_device`` will be a select widget with all of the user's
devices listed. Until the user has entered a valid username and
password, this will be empty and may be omitted.
- ``otp_token`` is where the user will enter their token.
- ``otp_challenge`` is a placeholder field that captures an
alternate submit button of the same name.
#. Override :meth:`~django.forms.Form.clean` to call :meth:`clean_otp`
after invoking the inherited :meth:`~django.forms.Form.clean`. See
:class:`OTPAuthenticationForm` for an example.
#. See :class:`OTPAuthenticationForm` for information about writing a
login template for this form. The file
``django_otp/templates/otp/admin/login.html`` is also a useful
example.
You will most likely be able to use :class:`OTPAuthenticationForm`,
:class:`~django_otp.admin.OTPAdminAuthenticationForm`, or
:class:`OTPTokenForm` directly. If these do not suit your needs--for
instance if your primary authentication is not by password--they should
serve as useful examples.
This mixin defines some error messages in
:attr:`OTPAuthenticationFormMixin.otp_error_messages`, which can be
overridden in subclasses (refer to the source for message codes). For
example::
class CustomAuthForm(OTPAuthenticationFormMixin, AuthenticationForm):
otp_error_messages = dict(OTPAuthenticationFormMixin.otp_error_messages,
token_required=_('Please enter your authentication code.'),
invalid_token=_('Incorrect authentication code. Please try again.'),
)
"""
otp_error_messages = {
'token_required': _('Please enter your OTP token.'),
'challenge_exception': _('Error generating challenge: {0}'),
'not_interactive': _('The selected OTP device is not interactive'),
'challenge_message': _('OTP Challenge: {0}'),
'invalid_token': _(
'Invalid token. Please make sure you have entered it correctly.'
),
'n_failed_attempts': ngettext_lazy(
"Verification temporarily disabled because of %(failure_count)d failed attempt, please try again soon.",
"Verification temporarily disabled because of %(failure_count)d failed attempts, please try again soon.",
"failure_count",
),
'verification_not_allowed': _(
"Verification of the token is currently disabled"
),
}
def clean_otp(self, user):
"""
Processes the ``otp_*`` fields.
:param user: A user that has been authenticated by the first factor
(such as a password).
:type user: :class:`~django.contrib.auth.models.User`
:raises: :exc:`~django.core.exceptions.ValidationError` if the user is
not fully authenticated by an OTP token.
"""
if user is None:
return
validation_error = None
with transaction.atomic():
try:
device = self._chosen_device(user)
token = self.cleaned_data.get('otp_token')
user.otp_device = None
try:
if self.cleaned_data.get('otp_challenge'):
self._handle_challenge(device)
elif token:
user.otp_device = self._verify_token(user, token, device)
else:
raise forms.ValidationError(
self.otp_error_messages['token_required'],
code='token_required',
)
finally:
if user.otp_device is None:
self._update_form(user)
except forms.ValidationError as e:
# Validation errors shouldn't abort the transaction, so we have
# to carefully transport them out.
validation_error = e
if validation_error:
raise validation_error
def _chosen_device(self, user):
device_id = self.cleaned_data.get('otp_device')
if device_id:
device = Device.from_persistent_id(device_id, for_verify=True)
else:
device = None
# SECURITY: The form doesn't validate otp_device for us, since we don't
# have the list of choices until we authenticate the user. Without the
# following, an attacker could authenticate using some other user's OTP
# device.
if (device is not None) and (device.user_id != user.pk):
device = None
return device
def _handle_challenge(self, device):
try:
challenge = device.generate_challenge() if (device is not None) else None
except Exception as e:
raise forms.ValidationError(
self.otp_error_messages['challenge_exception'].format(e),
code='challenge_exception',
)
else:
if challenge is None:
raise forms.ValidationError(
self.otp_error_messages['not_interactive'], code='not_interactive'
)
else:
raise forms.ValidationError(
self.otp_error_messages['challenge_message'].format(challenge),
code='challenge_message',
)
def _verify_token(self, user, token, device=None):
if device is not None:
verify_is_allowed, extra = device.verify_is_allowed()
if not verify_is_allowed:
# Try to match specific conditions we know about.
if (
'reason' in extra
and extra['reason'] == VerifyNotAllowed.N_FAILED_ATTEMPTS
):
raise forms.ValidationError(
self.otp_error_messages['n_failed_attempts'] % extra
)
if 'error_message' in extra:
raise forms.ValidationError(extra['error_message'])
# Fallback to generic message otherwise.
raise forms.ValidationError(
self.otp_error_messages['verification_not_allowed']
)
device = device if device.verify_token(token) else None
else:
device = match_token(user, token)
if device is None:
raise forms.ValidationError(
self.otp_error_messages['invalid_token'], code='invalid_token'
)
return device
def _update_form(self, user):
if 'otp_device' in self.fields:
self.fields['otp_device'].widget.choices = self.device_choices(user)
if 'password' in self.fields:
self.fields['password'].widget.render_value = True
@staticmethod
def device_choices(user):
return list((d.persistent_id, d.name) for d in devices_for_user(user))
[docs]
class OTPAuthenticationForm(OTPAuthenticationFormMixin, AuthenticationForm):
"""
This form provides the one-stop OTP authentication solution. It should
only be used when two-factor authentication is required: it does not
have an OTP-optional mode. The form has four fields:
#. ``username`` is inherited from
:class:`~django.contrib.auth.forms.AuthenticationForm`.
#. ``password`` is inherited from
:class:`~django.contrib.auth.forms.AuthenticationForm`.
#. ``otp_device`` uses a :class:`~django.forms.Select` to allow the user
to choose one of their registered devices. It will be empty as long
as ``form.get_user()`` is ``None`` and should generally be omitted
from the template in that case.
#. ``otp_token`` is the field for entering an OTP token. It should
always be included.
In addition, if ``form.get_user()`` is not ``None``, the template should
include an additional submit button named ``otp_challenge``. Pressing this
button when ``otp_device`` is set to an interactive device will cause us to
generate a challenge value for the user. Pressing the challenge button with
a non-interactive device selected has no effect.
The intended behavior of the form is as follows:
- Initially the ``username``, ``password``, and ``otp_token`` fields
should be visible. Validation of ``username`` and ``password`` is the
same as for :class:`~django.contrib.auth.forms.AuthenticationForm`.
If we are able to authenticate the user based on username and password,
then one of two things happens:
- If the user submitted an OTP token, we will enumerate all of the
user's OTP devices, asking each one to verify it in turn. If one
of them succeeds, then authentication is fully successful and the
user is logged in.
- If the user did not submit an OTP token or none of user's devices
accepted it, then a
:exc:`~django.core.exceptions.ValidationError` is raised.
- In either case, as long as the user is authenticated by their
password, ``form.get_user()`` will return the authenticated
:class:`~django.contrib.auth.models.User` object. From here on, this
documentation assumes that username/password authentication succeeds
on all subsequent submissions. If validation was not successful, then
the form will be displayed again and this time the template should be
sure to include the (now populated) ``otp_device`` field as well as
the ``otp_challenge`` submit button.
- The user will then have to choose a specific device to authenticate
against (or accept the default). If they press the ``otp_challenge``
button, we will ask that device to generate a challenge. The device
will return a message for the user, which will be incorporated into
the :exc:`~django.core.exceptions.ValidationError` message.
- If the user presses any other submit button, we will authenticate the
username and password as always and then verify the OTP token against
the chosen device. When that succeeds, authentication and
verification are successful and the user is logged in.
Error messages can be customized in subclasses; see
:class:`OTPAuthenticationFormMixin`.
"""
otp_device = forms.CharField(required=False, widget=forms.Select)
otp_token = forms.CharField(
required=False, widget=forms.TextInput(attrs={'autocomplete': 'off'})
)
# This is a placeholder field that allows us to detect when the user clicks
# the otp_challenge submit button.
otp_challenge = forms.CharField(required=False)
def clean(self):
self.cleaned_data = super().clean()
self.clean_otp(self.get_user())
return self.cleaned_data
[docs]
class OTPTokenForm(OTPAuthenticationFormMixin, forms.Form):
"""
A form that verifies an authenticated user. It looks very much like
:class:`~django_otp.forms.OTPAuthenticationForm`, but without the username
and password. The first argument must be an authenticated user; for an
example of using this in a login view, see the source for
:class:`django_otp.views.LoginView`.
This form will ask the user to choose one of their registered devices and
enter an OTP token. Validation will succeed if the token is verified. See
:class:`~django_otp.forms.OTPAuthenticationForm` for details on writing a
compatible template (leaving out the username and password, of course).
Error messages can be customized in subclasses; see
:class:`OTPAuthenticationFormMixin`.
:param user: An authenticated user.
:type user: :class:`~django.contrib.auth.models.User`
:param request: The current request.
:type request: :class:`~django.http.HttpRequest`
"""
otp_device = forms.ChoiceField(choices=[])
otp_token = forms.CharField(
required=False, widget=forms.TextInput(attrs={'autocomplete': 'off'})
)
otp_challenge = forms.CharField(required=False)
def __init__(self, user, request=None, *args, **kwargs):
super().__init__(*args, **kwargs)
self.user = user
self.fields['otp_device'].choices = self.device_choices(user)
def clean(self):
super().clean()
self.clean_otp(self.user)
return self.cleaned_data
def get_user(self):
return self.user