From 6612f729ecc7cebcd2c688d97eab7b30e3bc9714 Mon Sep 17 00:00:00 2001 From: Jens L Date: Mon, 4 Sep 2023 11:45:14 +0200 Subject: [PATCH] stages/authenticator: vendor otp (#6741) * initial import Signed-off-by: Jens Langhammer * update imports Signed-off-by: Jens Langhammer * remove email and hotp for now Signed-off-by: Jens Langhammer * remove things we don't need and clean up Signed-off-by: Jens Langhammer * initial merge static Signed-off-by: Jens Langhammer * initial merge totp Signed-off-by: Jens Langhammer * more fixes Signed-off-by: Jens Langhammer * fix migrations Signed-off-by: Jens Langhammer * update webui Signed-off-by: Jens Langhammer * add system migration Signed-off-by: Jens Langhammer * more cleanup, add doctests to test_runner Signed-off-by: Jens Langhammer * more cleanup Signed-off-by: Jens Langhammer * fixup more lint Signed-off-by: Jens Langhammer * cleanup last tests Signed-off-by: Jens Langhammer * update docstrings Signed-off-by: Jens Langhammer * fix tests Signed-off-by: Jens Langhammer * implement SerializerModel Signed-off-by: Jens Langhammer * fix web format Signed-off-by: Jens Langhammer --------- Signed-off-by: Jens Langhammer --- authentik/core/api/devices.py | 4 +- authentik/core/tests/utils.py | 4 +- authentik/enterprise/policy.py | 34 +- authentik/events/middleware.py | 2 +- authentik/lib/expression/evaluator.py | 2 +- authentik/root/settings.py | 1 + authentik/root/test_runner.py | 2 +- authentik/stages/authenticator/__init__.py | 129 ++++++ authentik/stages/authenticator/apps.py | 10 + authentik/stages/authenticator/models.py | 401 ++++++++++++++++++ authentik/stages/authenticator/oath.py | 199 +++++++++ authentik/stages/authenticator/tests.py | 220 ++++++++++ authentik/stages/authenticator/util.py | 86 ++++ authentik/stages/authenticator_duo/models.py | 2 +- authentik/stages/authenticator_sms/models.py | 2 +- authentik/stages/authenticator_static/api.py | 7 +- .../migrations/0008_initial.py | 70 +++ .../migrations/0009_throttling.py | 33 ++ .../stages/authenticator_static/models.py | 78 ++++ .../stages/authenticator_static/settings.py | 5 - .../stages/authenticator_static/signals.py | 2 +- .../stages/authenticator_static/stage.py | 7 +- .../stages/authenticator_static/tests.py | 45 +- authentik/stages/authenticator_totp/api.py | 3 +- .../migrations/0008_initial.py | 98 +++++ .../migrations/0009_auto_20190420_0723.py | 29 ++ .../migrations/0010_alter_totpdevice_key.py | 28 ++ authentik/stages/authenticator_totp/models.py | 193 ++++++++- .../stages/authenticator_totp/settings.py | 3 - authentik/stages/authenticator_totp/stage.py | 3 +- authentik/stages/authenticator_totp/tests.py | 185 +++++++- .../authenticator_validate/challenge.py | 4 +- .../stages/authenticator_validate/settings.py | 4 - .../stages/authenticator_validate/stage.py | 4 +- .../authenticator_validate/tests/test_totp.py | 4 +- .../stages/authenticator_webauthn/models.py | 2 +- blueprints/schema.json | 101 +++++ lifecycle/system_migrations/otp_merge.py | 47 ++ poetry.lock | 33 +- pyproject.toml | 6 +- schema.yml | 33 +- tests/e2e/test_flows_authenticators.py | 12 +- web/src/elements/user/UserDevicesList.ts | 4 +- .../user/user-settings/mfa/MFADeviceForm.ts | 4 +- .../user/user-settings/mfa/MFADevicesPage.ts | 8 +- 45 files changed, 2046 insertions(+), 107 deletions(-) create mode 100644 authentik/stages/authenticator/__init__.py create mode 100644 authentik/stages/authenticator/apps.py create mode 100644 authentik/stages/authenticator/models.py create mode 100644 authentik/stages/authenticator/oath.py create mode 100644 authentik/stages/authenticator/tests.py create mode 100644 authentik/stages/authenticator/util.py create mode 100644 authentik/stages/authenticator_static/migrations/0008_initial.py create mode 100644 authentik/stages/authenticator_static/migrations/0009_throttling.py delete mode 100644 authentik/stages/authenticator_static/settings.py create mode 100644 authentik/stages/authenticator_totp/migrations/0008_initial.py create mode 100644 authentik/stages/authenticator_totp/migrations/0009_auto_20190420_0723.py create mode 100644 authentik/stages/authenticator_totp/migrations/0010_alter_totpdevice_key.py delete mode 100644 authentik/stages/authenticator_validate/settings.py create mode 100644 lifecycle/system_migrations/otp_merge.py diff --git a/authentik/core/api/devices.py b/authentik/core/api/devices.py index d792791e9..777361533 100644 --- a/authentik/core/api/devices.py +++ b/authentik/core/api/devices.py @@ -1,6 +1,4 @@ """Authenticator Devices API Views""" -from django_otp import device_classes, devices_for_user -from django_otp.models import Device from drf_spectacular.types import OpenApiTypes from drf_spectacular.utils import OpenApiParameter, extend_schema from rest_framework.fields import BooleanField, CharField, IntegerField, SerializerMethodField @@ -10,6 +8,8 @@ from rest_framework.response import Response from rest_framework.viewsets import ViewSet from authentik.core.api.utils import MetaNameSerializer +from authentik.stages.authenticator import device_classes, devices_for_user +from authentik.stages.authenticator.models import Device class DeviceSerializer(MetaNameSerializer): diff --git a/authentik/core/tests/utils.py b/authentik/core/tests/utils.py index 2406edc2a..59294e6fd 100644 --- a/authentik/core/tests/utils.py +++ b/authentik/core/tests/utils.py @@ -25,10 +25,10 @@ def create_test_admin_user(name: Optional[str] = None, **kwargs) -> User: """Generate a test-admin user""" uid = generate_id(20) if not name else name group = Group.objects.create(name=uid, is_superuser=True) + kwargs.setdefault("email", f"{uid}@goauthentik.io") + kwargs.setdefault("username", uid) user: User = User.objects.create( - username=uid, name=uid, - email=f"{uid}@goauthentik.io", **kwargs, ) user.set_password(uid) diff --git a/authentik/enterprise/policy.py b/authentik/enterprise/policy.py index 710222816..0c714322a 100644 --- a/authentik/enterprise/policy.py +++ b/authentik/enterprise/policy.py @@ -1,44 +1,30 @@ """Enterprise license policies""" from typing import Optional -from rest_framework.serializers import BaseSerializer - from authentik.core.models import User, UserTypes from authentik.enterprise.models import LicenseKey -from authentik.policies.models import Policy from authentik.policies.types import PolicyRequest, PolicyResult from authentik.policies.views import PolicyAccessView -class EnterprisePolicy(Policy): - """Check that a user is correctly licensed for the request""" - - @property - def component(self) -> str: - return "" - - @property - def serializer(self) -> type[BaseSerializer]: - raise NotImplementedError - - def passes(self, request: PolicyRequest) -> PolicyResult: - if not LicenseKey.get_total().is_valid(): - return PolicyResult(False) - if request.user.type != UserTypes.INTERNAL: - return PolicyResult(False) - return PolicyResult(True) - - class EnterprisePolicyAccessView(PolicyAccessView): """PolicyAccessView which also checks enterprise licensing""" + def check_license(self): + """Check license""" + if not LicenseKey.get_total().is_valid(): + return False + if self.request.user.type != UserTypes.INTERNAL: + return False + return True + def user_has_access(self, user: Optional[User] = None) -> PolicyResult: user = user or self.request.user request = PolicyRequest(user) request.http_request = self.request result = super().user_has_access(user) - enterprise_result = EnterprisePolicy().passes(request) - if not enterprise_result.passing: + enterprise_result = self.check_license() + if not enterprise_result: return enterprise_result return result diff --git a/authentik/events/middleware.py b/authentik/events/middleware.py index 0215f8611..a722d8e4a 100644 --- a/authentik/events/middleware.py +++ b/authentik/events/middleware.py @@ -9,7 +9,6 @@ from django.core.exceptions import SuspiciousOperation from django.db.models import Model from django.db.models.signals import m2m_changed, post_save, pre_delete from django.http import HttpRequest, HttpResponse -from django_otp.plugins.otp_static.models import StaticToken from guardian.models import UserObjectPermission from authentik.core.models import ( @@ -30,6 +29,7 @@ from authentik.outposts.models import OutpostServiceConnection from authentik.policies.models import Policy, PolicyBindingModel from authentik.providers.oauth2.models import AccessToken, AuthorizationCode, RefreshToken from authentik.providers.scim.models import SCIMGroup, SCIMUser +from authentik.stages.authenticator_static.models import StaticToken IGNORED_MODELS = ( Event, diff --git a/authentik/lib/expression/evaluator.py b/authentik/lib/expression/evaluator.py index 9580280e3..7163dbf74 100644 --- a/authentik/lib/expression/evaluator.py +++ b/authentik/lib/expression/evaluator.py @@ -7,7 +7,6 @@ from typing import Any, Iterable, Optional from cachetools import TLRUCache, cached from django.core.exceptions import FieldError -from django_otp import devices_for_user from guardian.shortcuts import get_anonymous_user from rest_framework.serializers import ValidationError from sentry_sdk.hub import Hub @@ -20,6 +19,7 @@ from authentik.lib.utils.http import get_http_session from authentik.policies.models import Policy, PolicyBinding from authentik.policies.process import PolicyProcess from authentik.policies.types import PolicyRequest, PolicyResult +from authentik.stages.authenticator import devices_for_user LOGGER = get_logger() diff --git a/authentik/root/settings.py b/authentik/root/settings.py index af8f85dbf..0fa099755 100644 --- a/authentik/root/settings.py +++ b/authentik/root/settings.py @@ -81,6 +81,7 @@ INSTALLED_APPS = [ "authentik.sources.oauth", "authentik.sources.plex", "authentik.sources.saml", + "authentik.stages.authenticator", "authentik.stages.authenticator_duo", "authentik.stages.authenticator_sms", "authentik.stages.authenticator_static", diff --git a/authentik/root/test_runner.py b/authentik/root/test_runner.py index febcedf9c..5141613b0 100644 --- a/authentik/root/test_runner.py +++ b/authentik/root/test_runner.py @@ -20,7 +20,7 @@ class PytestTestRunner: # pragma: no cover self.failfast = failfast self.keepdb = keepdb - self.args = ["-vv", "--full-trace"] + self.args = [] if self.failfast: self.args.append("--exitfirst") if self.keepdb: diff --git a/authentik/stages/authenticator/__init__.py b/authentik/stages/authenticator/__init__.py new file mode 100644 index 000000000..a6d3087e8 --- /dev/null +++ b/authentik/stages/authenticator/__init__.py @@ -0,0 +1,129 @@ +"""Authenticator devices helpers""" +from django.db import transaction + + +def verify_token(user, device_id, token): + """ + Attempts to verify a :term:`token` against a specific device, identified by + :attr:`~authentik.stages.authenticator.models.Device.persistent_id`. + + This wraps the verification process in a transaction to ensure that things + like throttling polices are properly enforced. + + :param user: The user supplying the token. + :type user: :class:`~django.contrib.auth.models.User` + + :param str device_id: A device's persistent_id value. + + :param str token: An OTP token to verify. + + :returns: The device that accepted ``token``, if any. + :rtype: :class:`~authentik.stages.authenticator.models.Device` or ``None`` + + """ + from authentik.stages.authenticator.models import Device + + verified = None + with transaction.atomic(): + device = Device.from_persistent_id(device_id, for_verify=True) + if (device is not None) and (device.user_id == user.pk) and device.verify_token(token): + verified = device + + return verified + + +def match_token(user, token): + """ + Attempts to verify a :term:`token` on every device attached to the given + user until one of them succeeds. + + .. warning:: + + This originally existed for more convenient integration with the admin + site. Its use is no longer recommended and it is not guaranteed to + interact well with more recent features (such as throttling). Tokens + should always be verified against specific devices. + + :param user: The user supplying the token. + :type user: :class:`~django.contrib.auth.models.User` + + :param str token: An OTP token to verify. + + :returns: The device that accepted ``token``, if any. + :rtype: :class:`~authentik.stages.authenticator.models.Device` or ``None`` + """ + with transaction.atomic(): + for device in devices_for_user(user, for_verify=True): + if device.verify_token(token): + break + else: + device = None + + return device + + +def devices_for_user(user, confirmed=True, for_verify=False): + """ + Return an iterable of all devices registered to the given user. + + Returns an empty iterable for anonymous users. + + :param user: standard or custom user object. + :type user: :class:`~django.contrib.auth.models.User` + + :param bool confirmed: If ``None``, all matching devices are returned. + Otherwise, this can be any true or false value to limit the query + to confirmed or unconfirmed devices, respectively. + + :param bool for_verify: If ``True``, we'll load the devices with + :meth:`~django.db.models.query.QuerySet.select_for_update` to prevent + concurrent verifications from succeeding. In which case, this must be + called inside a transaction. + + :rtype: iterable + """ + if user.is_anonymous: + return + + for model in device_classes(): + device_set = model.objects.devices_for_user(user, confirmed=confirmed) + if for_verify: + device_set = device_set.select_for_update() + + yield from device_set + + +def user_has_device(user, confirmed=True): + """ + Return ``True`` if the user has at least one device. + + Returns ``False`` for anonymous users. + + :param user: standard or custom user object. + :type user: :class:`~django.contrib.auth.models.User` + + :param confirmed: If ``None``, all matching devices are considered. + Otherwise, this can be any true or false value to limit the query + to confirmed or unconfirmed devices, respectively. + """ + try: + next(devices_for_user(user, confirmed=confirmed)) + except StopIteration: + has_device = False + else: + has_device = True + + return has_device + + +def device_classes(): + """ + Returns an iterable of all loaded device models. + """ + from django.apps import apps # isort: skip + from authentik.stages.authenticator.models import Device + + for config in apps.get_app_configs(): + for model in config.get_models(): + if issubclass(model, Device): + yield model diff --git a/authentik/stages/authenticator/apps.py b/authentik/stages/authenticator/apps.py new file mode 100644 index 000000000..5fb866e2d --- /dev/null +++ b/authentik/stages/authenticator/apps.py @@ -0,0 +1,10 @@ +"""Authenticator""" +from django.apps import AppConfig + + +class AuthentikStageAuthenticatorConfig(AppConfig): + """Authenticator App config""" + + name = "authentik.stages.authenticator" + label = "authentik_stages_authenticator" + verbose_name = "authentik Stages.Authenticator" diff --git a/authentik/stages/authenticator/models.py b/authentik/stages/authenticator/models.py new file mode 100644 index 000000000..0404a0668 --- /dev/null +++ b/authentik/stages/authenticator/models.py @@ -0,0 +1,401 @@ +"""Base authenticator models""" +from datetime import timedelta + +from django.apps import apps +from django.core.exceptions import ObjectDoesNotExist +from django.db import models +from django.utils import timezone +from django.utils.functional import cached_property + +from authentik.core.models import User +from authentik.stages.authenticator.util import random_number_token + + +class DeviceManager(models.Manager): + """ + The :class:`~django.db.models.Manager` object installed as + ``Device.objects``. + """ + + def devices_for_user(self, user, confirmed=None): + """ + Returns a queryset for all devices of this class that belong to the + given user. + + :param user: The user. + :type user: :class:`~django.contrib.auth.models.User` + + :param confirmed: If ``None``, all matching devices are returned. + Otherwise, this can be any true or false value to limit the query + to confirmed or unconfirmed devices, respectively. + """ + devices = self.model.objects.filter(user=user) + if confirmed is not None: + devices = devices.filter(confirmed=bool(confirmed)) + + return devices + + +class Device(models.Model): + """ + Abstract base model for a :term:`device` attached to a user. Plugins must + subclass this to define their OTP models. + + .. _unsaved_device_warning: + + .. warning:: + + OTP devices are inherently stateful. For example, verifying a token is + logically a mutating operation on the device, which may involve + incrementing a counter or otherwise consuming a token. A device must be + committed to the database before it can be used in any way. + + .. attribute:: user + + *ForeignKey*: Foreign key to your user model, as configured by + :setting:`AUTH_USER_MODEL` (:class:`~django.contrib.auth.models.User` + by default). + + .. attribute:: name + + *CharField*: A human-readable name to help the user identify their + devices. + + .. attribute:: confirmed + + *BooleanField*: A boolean value that tells us whether this device has + been confirmed as valid. It defaults to ``True``, but subclasses or + individual deployments can force it to ``False`` if they wish to create + a device and then ask the user for confirmation. As a rule, built-in + APIs that enumerate devices will only include those that are confirmed. + + .. attribute:: objects + + A :class:`~authentik.stages.authenticator.models.DeviceManager`. + """ + + user = models.ForeignKey( + User, + help_text="The user that this device belongs to.", + on_delete=models.CASCADE, + ) + + name = models.CharField(max_length=64, help_text="The human-readable name of this device.") + + confirmed = models.BooleanField(default=True, help_text="Is this device ready for use?") + + objects = DeviceManager() + + class Meta: + abstract = True + + def __str__(self): + try: + user = self.user + except ObjectDoesNotExist: + user = None + + return "{0} ({1})".format(self.name, user) + + @property + def persistent_id(self): + """ + A stable device identifier for forms and APIs. + """ + return "{0}/{1}".format(self.model_label(), self.id) + + @classmethod + def model_label(cls): + """ + Returns an identifier for this Django model class. + + This is just the standard "." form. + + """ + return "{0}.{1}".format(cls._meta.app_label, cls._meta.model_name) + + @classmethod + def from_persistent_id(cls, persistent_id, for_verify=False): + """ + Loads a device from its persistent id:: + + device == Device.from_persistent_id(device.persistent_id) + + :param bool for_verify: If ``True``, we'll load the device with + :meth:`~django.db.models.query.QuerySet.select_for_update` to + prevent concurrent verifications from succeeding. In which case, + this must be called inside a transaction. + + """ + device = None + + try: + model_label, device_id = persistent_id.rsplit("/", 1) + app_label, model_name = model_label.split(".") + + device_cls = apps.get_model(app_label, model_name) + if issubclass(device_cls, Device): + device_set = device_cls.objects.filter(id=int(device_id)) + if for_verify: + device_set = device_set.select_for_update() + device = device_set.first() + except (ValueError, LookupError): + pass + + return device + + def is_interactive(self): + """ + Returns ``True`` if this is an interactive device. The default + implementation returns ``True`` if + :meth:`~authentik.stages.authenticator.models.Device.generate_challenge` has been + overridden, but subclasses are welcome to provide smarter + implementations. + + :rtype: bool + """ + return not hasattr(self.generate_challenge, "stub") + + def generate_challenge(self): + """ + Generates a challenge value that the user will need to produce a token. + This method is permitted to have side effects, such as transmitting + information to the user through some other channel (email or SMS, + perhaps). And, of course, some devices may need to commit the + challenge to the database. + + :returns: A message to the user. This should be a string that fits + comfortably in the template ``'OTP Challenge: {0}'``. This may + return ``None`` if this device is not interactive. + :rtype: string or ``None`` + + :raises: Any :exc:`~exceptions.Exception` is permitted. Callers should + trap ``Exception`` and report it to the user. + """ + return None + + generate_challenge.stub = True + + def verify_is_allowed(self): + """ + Checks whether it is permissible to call :meth:`verify_token`. If it is + allowed, returns ``(True, None)``. Otherwise returns ``(False, + data_dict)``, where ``data_dict`` contains extra information, defined + by the implementation. + + This method can be used to implement throttling or locking, for + example. Client code should check this method before calling + :meth:`verify_token` and report problems to the user. + + To report specific problems, the data dictionary can return include a + ``'reason'`` member with a value from the constants in + :class:`VerifyNotAllowed`. Otherwise, an ``'error_message'`` member + should be provided with an error message. + + :meth:`verify_token` should also call this method and return False if + verification is not allowed. + + :rtype: (bool, dict or ``None``) + + """ + return (True, None) + + def verify_token(self, token): + """ + Verifies a token. As a rule, the token should no longer be valid if + this returns ``True``. + + :param str token: The OTP token provided by the user. + :rtype: bool + """ + return False + + +class SideChannelDevice(Device): + """ + Abstract base model for a side-channel :term:`device` attached to a user. + + This model implements token generation, verification and expiration, so the + concrete devices only have to implement delivery. + + """ + + token = models.CharField(max_length=16, blank=True, null=True) + + valid_until = models.DateTimeField( + default=timezone.now, + help_text="The timestamp of the moment of expiry of the saved token.", + ) + + class Meta: + abstract = True + + def generate_token(self, length=6, valid_secs=300, commit=True): + """ + Generates a token of the specified length, then sets it on the model + and sets the expiration of the token on the model. + + Pass 'commit=False' to avoid calling self.save(). + + :param int length: Number of decimal digits in the generated token. + :param int valid_secs: Amount of seconds the token should be valid. + :param bool commit: Whether to autosave the generated token. + + """ + self.token = random_number_token(length) + self.valid_until = timezone.now() + timedelta(seconds=valid_secs) + if commit: + self.save() + + def verify_token(self, token): + """ + Verifies a token by content and expiry. + + On success, the token is cleared and the device saved. + + :param str token: The OTP token provided by the user. + :rtype: bool + + """ + _now = timezone.now() + + if (self.token is not None) and (token == self.token) and (_now < self.valid_until): + self.token = None + self.valid_until = _now + self.save() + + return True + return False + + +class VerifyNotAllowed: + """ + Constants that may be returned in the ``reason`` member of the extra + information dictionary returned by + :meth:`~authentik.stages.authenticator.models.Device.verify_is_allowed` + + .. data:: N_FAILED_ATTEMPTS + + Indicates that verification is disallowed because of ``n`` successive + failed attempts. The data dictionary should include the value of ``n`` + in member ``failure_count`` + + """ + + N_FAILED_ATTEMPTS = "N_FAILED_ATTEMPTS" + + +class ThrottlingMixin(models.Model): + """ + Mixin class for models that want throttling behaviour. + + This implements exponential back-off for verifying tokens. Subclasses must + implement :meth:`get_throttle_factor`, and must use the + :meth:`verify_is_allowed`, :meth:`throttle_reset` and + :meth:`throttle_increment` methods from within their verify_token() method. + + See the implementation of + :class:`~authentik.stages.authenticator.plugins.otp_email.models.EmailDevice` for an example. + + """ + + throttling_failure_timestamp = models.DateTimeField( + null=True, + blank=True, + default=None, + help_text=( + "A timestamp of the last failed verification attempt. " + "Null if last attempt succeeded." + ), + ) + + throttling_failure_count = models.PositiveIntegerField( + default=0, help_text="Number of successive failed attempts." + ) + + def verify_is_allowed(self): + """ + If verification is allowed, returns ``(True, None)``. + Otherwise, returns ``(False, data_dict)``. + + ``data_dict`` contains further information. Currently it can be:: + + { + 'reason': VerifyNotAllowed.N_FAILED_ATTEMPTS, + 'failure_count': n + } + + where ``n`` is the number of successive failures. See + :class:`~authentik.stages.authenticator.models.VerifyNotAllowed`. + + """ + if ( + self.throttling_enabled + and self.throttling_failure_count > 0 + and self.throttling_failure_timestamp is not None + ): + now = timezone.now() + delay = (now - self.throttling_failure_timestamp).total_seconds() + # Required delays should be 1, 2, 4, 8 ... + delay_required = self.get_throttle_factor() * (2 ** (self.throttling_failure_count - 1)) + if delay < delay_required: + return ( + False, + { + "reason": VerifyNotAllowed.N_FAILED_ATTEMPTS, + "failure_count": self.throttling_failure_count, + "locked_until": self.throttling_failure_timestamp + + timedelta(seconds=delay_required), + }, + ) + + return super().verify_is_allowed() + + def throttle_reset(self, commit=True): + """ + Call this method to reset throttling (normally when a verify attempt + succeeded). + + Pass 'commit=False' to avoid calling self.save(). + + """ + self.throttling_failure_timestamp = None + self.throttling_failure_count = 0 + if commit: + self.save() + + def throttle_increment(self, commit=True): + """ + Call this method to increase throttling (normally when a verify attempt + failed). + + Pass 'commit=False' to avoid calling self.save(). + + """ + self.throttling_failure_timestamp = timezone.now() + self.throttling_failure_count += 1 + if commit: + self.save() + + @cached_property + def throttling_enabled(self) -> bool: + """Check if throttling is enabled""" + return self.get_throttle_factor() > 0 + + def get_throttle_factor(self): # pragma: no cover + """ + This must be implemented to return the throttle factor. + + The number of seconds required between verification attempts will be + :math:`c2^{n-1}` where `c` is this factor and `n` is the number of + previous failures. A factor of 1 translates to delays of 1, 2, 4, 8, + etc. seconds. A factor of 0 disables the throttling. + + Normally this is just a wrapper for a plugin-specific setting like + :setting:`OTP_EMAIL_THROTTLE_FACTOR`. + + """ + raise NotImplementedError() + + class Meta: + abstract = True diff --git a/authentik/stages/authenticator/oath.py b/authentik/stages/authenticator/oath.py new file mode 100644 index 000000000..443079067 --- /dev/null +++ b/authentik/stages/authenticator/oath.py @@ -0,0 +1,199 @@ +"""OATH helpers""" +import hmac +from hashlib import sha1 +from struct import pack +from time import time + + +# pylint: disable=invalid-name +def hotp(key: bytes, counter: int, digits=6) -> int: + """ + Implementation of the HOTP algorithm from `RFC 4226 + `_. + + :param bytes key: The shared secret. A 20-byte string is recommended. + :param int counter: The password counter. + :param int digits: The number of decimal digits to generate. + + :returns: The HOTP token. + :rtype: int + + >>> key = b'12345678901234567890' + >>> for c in range(10): + ... hotp(key, c) + 755224 + 287082 + 359152 + 969429 + 338314 + 254676 + 287922 + 162583 + 399871 + 520489 + """ + msg = pack(b">Q", counter) + hs = hmac.new(key, msg, sha1).digest() + hs = list(iter(hs)) + + offset = hs[19] & 0x0F + bin_code = ( + (hs[offset] & 0x7F) << 24 | hs[offset + 1] << 16 | hs[offset + 2] << 8 | hs[offset + 3] + ) + return bin_code % pow(10, digits) + + +def totp(key: bytes, step=30, t0=0, digits=6, drift=0) -> int: + """ + Implementation of the TOTP algorithm from `RFC 6238 + `_. + + :param bytes key: The shared secret. A 20-byte string is recommended. + :param int step: The time step in seconds. The time-based code changes + every ``step`` seconds. + :param int t0: The Unix time at which to start counting time steps. + :param int digits: The number of decimal digits to generate. + :param int drift: The number of time steps to add or remove. Delays and + clock differences might mean that you have to look back or forward a + step or two in order to match a token. + + :returns: The TOTP token. + :rtype: int + + >>> key = b'12345678901234567890' + >>> now = int(time()) + >>> for delta in range(0, 200, 20): + ... totp(key, t0=(now-delta)) + 755224 + 755224 + 287082 + 359152 + 359152 + 969429 + 338314 + 338314 + 254676 + 287922 + """ + return TOTP(key, step, t0, digits, drift).token() + + +class TOTP: + """ + An alternate TOTP interface. + + This provides access to intermediate steps of the computation. This is a + living object: the return values of ``t`` and ``token`` will change along + with other properties and with the passage of time. + + :param bytes key: The shared secret. A 20-byte string is recommended. + :param int step: The time step in seconds. The time-based code changes + every ``step`` seconds. + :param int t0: The Unix time at which to start counting time steps. + :param int digits: The number of decimal digits to generate. + :param int drift: The number of time steps to add or remove. Delays and + clock differences might mean that you have to look back or forward a + step or two in order to match a token. + + >>> key = b'12345678901234567890' + >>> totp = TOTP(key) + >>> totp.time = 0 + >>> totp.t() + 0 + >>> totp.token() + 755224 + >>> totp.time = 30 + >>> totp.t() + 1 + >>> totp.token() + 287082 + >>> totp.verify(287082) + True + >>> totp.verify(359152) + False + >>> totp.verify(359152, tolerance=1) + True + >>> totp.drift + 1 + >>> totp.drift = 0 + >>> totp.verify(359152, tolerance=1, min_t=3) + False + >>> totp.drift + 0 + >>> del totp.time + >>> totp.t0 = int(time()) - 60 + >>> totp.t() + 2 + >>> totp.token() + 359152 + """ + + # pylint: disable=too-many-arguments + def __init__(self, key: bytes, step=30, t0=0, digits=6, drift=0): + self.key = key + self.step = step + self.t0 = t0 + self.digits = digits + self.drift = drift + self._time = None + + def token(self): + """The computed TOTP token.""" + return hotp(self.key, self.t(), digits=self.digits) + + def t(self): + """The computed time step.""" + return ((int(self.time) - self.t0) // self.step) + self.drift + + @property + def time(self): + """ + The current time. + + By default, this returns time.time() each time it is accessed. If you + want to generate a token at a specific time, you can set this property + to a fixed value instead. Deleting the value returns it to its 'live' + state. + + """ + return self._time if (self._time is not None) else time() + + @time.setter + def time(self, value): + self._time = value + + @time.deleter + def time(self): + self._time = None + + def verify(self, token, tolerance=0, min_t=None): + """ + A high-level verification helper. + + :param int token: The provided token. + :param int tolerance: The amount of clock drift you're willing to + accommodate, in steps. We'll look for the token at t values in + [t - tolerance, t + tolerance]. + :param int min_t: The minimum t value we'll accept. As a rule, this + should be one larger than the largest t value of any previously + accepted token. + :rtype: bool + + Iff this returns True, `self.drift` will be updated to reflect the + drift value that was necessary to match the token. + + """ + drift_orig = self.drift + verified = False + + for offset in range(-tolerance, tolerance + 1): + self.drift = drift_orig + offset + if (min_t is not None) and (self.t() < min_t): + continue + if self.token() == token: + verified = True + break + else: + self.drift = drift_orig + + return verified diff --git a/authentik/stages/authenticator/tests.py b/authentik/stages/authenticator/tests.py new file mode 100644 index 000000000..9a619828a --- /dev/null +++ b/authentik/stages/authenticator/tests.py @@ -0,0 +1,220 @@ +"""Base authenticator tests""" +from datetime import timedelta +from threading import Thread + +from django.contrib.auth.models import AnonymousUser +from django.db import connection +from django.test import TestCase, TransactionTestCase +from django.test.utils import override_settings +from django.utils import timezone +from freezegun import freeze_time + +from authentik.core.tests.utils import create_test_admin_user +from authentik.lib.generators import generate_id +from authentik.stages.authenticator import match_token, user_has_device, verify_token +from authentik.stages.authenticator.models import Device, VerifyNotAllowed + + +class TestThread(Thread): + "Django testing quirk: threads have to close their DB connections." + + __test__ = False + + def run(self): + super().run() + connection.close() + + +class ThrottlingTestMixin: + """ + Generic tests for throttled devices. + + Any concrete device implementation that uses throttling should define a + TestCase subclass that includes this as a base class. This will help verify + a correct integration of ThrottlingMixin. + + Subclasses are responsible for populating self.device with a device to test + as well as implementing methods to generate tokens to test with. + + """ + + device: Device + + def valid_token(self): + """Returns a valid token to pass to our device under test.""" + raise NotImplementedError() + + def invalid_token(self): + """Returns an invalid token to pass to our device under test.""" + raise NotImplementedError() + + # + # Tests + # + + def test_delay_imposed_after_fail(self): + """Test delay imposed after fail""" + verified1 = self.device.verify_token(self.invalid_token()) + self.assertFalse(verified1) + verified2 = self.device.verify_token(self.valid_token()) + self.assertFalse(verified2) + + def test_delay_after_fail_expires(self): + """Test delay after fail expires""" + verified1 = self.device.verify_token(self.invalid_token()) + self.assertFalse(verified1) + with freeze_time() as frozen_time: + # With default settings initial delay is 1 second + frozen_time.tick(delta=timedelta(seconds=1.1)) + verified2 = self.device.verify_token(self.valid_token()) + self.assertTrue(verified2) + + def test_throttling_failure_count(self): + """Test throttling failure count""" + self.assertEqual(self.device.throttling_failure_count, 0) + for _ in range(0, 5): + self.device.verify_token(self.invalid_token()) + # Only the first attempt will increase throttling_failure_count, + # the others will all be within 1 second of first + # and therefore not count as attempts. + self.assertEqual(self.device.throttling_failure_count, 1) + + def test_verify_is_allowed(self): + """Test verify allowed""" + # Initially should be allowed + verify_is_allowed1, data1 = self.device.verify_is_allowed() + self.assertEqual(verify_is_allowed1, True) + self.assertEqual(data1, None) + + # After failure, verify is not allowed + with freeze_time(): + self.device.verify_token(self.invalid_token()) + verify_is_allowed2, data2 = self.device.verify_is_allowed() + self.assertEqual(verify_is_allowed2, False) + self.assertEqual( + data2, + { + "reason": VerifyNotAllowed.N_FAILED_ATTEMPTS, + "failure_count": 1, + "locked_until": timezone.now() + timezone.timedelta(seconds=1), + }, + ) + + # After a successful attempt, should be allowed again + with freeze_time() as frozen_time: + frozen_time.tick(delta=timedelta(seconds=1.1)) + self.device.verify_token(self.valid_token()) + + verify_is_allowed3, data3 = self.device.verify_is_allowed() + self.assertEqual(verify_is_allowed3, True) + self.assertEqual(data3, None) + + +@override_settings(OTP_STATIC_THROTTLE_FACTOR=0) +class APITestCase(TestCase): + """Test API""" + + def setUp(self): + self.alice = create_test_admin_user("alice") + self.bob = create_test_admin_user("bob") + device = self.alice.staticdevice_set.create() + self.valid = generate_id(length=16) + device.token_set.create(token=self.valid) + + def test_user_has_device(self): + """Test user_has_device""" + with self.subTest(user="anonymous"): + self.assertFalse(user_has_device(AnonymousUser())) + with self.subTest(user="alice"): + self.assertTrue(user_has_device(self.alice)) + with self.subTest(user="bob"): + self.assertFalse(user_has_device(self.bob)) + + def test_verify_token(self): + """Test verify_token""" + device = self.alice.staticdevice_set.first() + + verified = verify_token(self.alice, device.persistent_id, "bogus") + self.assertIsNone(verified) + + verified = verify_token(self.alice, device.persistent_id, self.valid) + self.assertIsNotNone(verified) + + def test_match_token(self): + """Test match_token""" + verified = match_token(self.alice, "bogus") + self.assertIsNone(verified) + + verified = match_token(self.alice, self.valid) + self.assertEqual(verified, self.alice.staticdevice_set.first()) + + +@override_settings(OTP_STATIC_THROTTLE_FACTOR=0) +class ConcurrencyTestCase(TransactionTestCase): + """Test concurrent verifications""" + + def setUp(self): + self.alice = create_test_admin_user("alice") + self.bob = create_test_admin_user("bob") + self.valid = generate_id(length=16) + for user in [self.alice, self.bob]: + device = user.staticdevice_set.create() + device.token_set.create(token=self.valid) + + def test_verify_token(self): + """Test verify_token in a thread""" + + class VerifyThread(Thread): + """Verifier thread""" + + __test__ = False + + def __init__(self, user, device_id, token): + super().__init__() + + self.user = user + self.device_id = device_id + self.token = token + + self.verified = None + + def run(self): + self.verified = verify_token(self.user, self.device_id, self.token) + connection.close() + + device = self.alice.staticdevice_set.get() + threads = [VerifyThread(device.user, device.persistent_id, self.valid) for _ in range(10)] + for thread in threads: + thread.start() + for thread in threads: + thread.join() + + self.assertEqual(sum(1 for t in threads if t.verified is not None), 1) + + def test_match_token(self): + """Test match_token in a thread""" + + class VerifyThread(Thread): + """Verifier thread""" + + __test__ = False + + def __init__(self, user, token): + super().__init__() + + self.user = user + self.token = token + + self.verified = None + + def run(self): + self.verified = match_token(self.user, self.token) + connection.close() + + threads = [VerifyThread(self.alice, self.valid) for _ in range(10)] + for thread in threads: + thread.start() + for thread in threads: + thread.join() + + self.assertEqual(sum(1 for t in threads if t.verified is not None), 1) diff --git a/authentik/stages/authenticator/util.py b/authentik/stages/authenticator/util.py new file mode 100644 index 000000000..26b9dc64d --- /dev/null +++ b/authentik/stages/authenticator/util.py @@ -0,0 +1,86 @@ +"""Authenticator utils""" +import random +import string +from binascii import unhexlify +from os import urandom + +from django.core.exceptions import ValidationError + + +def hex_validator(length=0): + """ + Returns a function to be used as a model validator for a hex-encoded + CharField. This is useful for secret keys of all kinds:: + + def key_validator(value): + return hex_validator(20)(value) + + key = models.CharField(max_length=40, + validators=[key_validator], help_text='A hex-encoded 20-byte secret key') + + :param int length: If greater than 0, validation will fail unless the + decoded value is exactly this number of bytes. + + :rtype: function + + >>> hex_validator()('0123456789abcdef') + >>> hex_validator(8)(b'0123456789abcdef') + >>> hex_validator()('phlebotinum') # doctest: +IGNORE_EXCEPTION_DETAIL + Traceback (most recent call last): + ... + ValidationError: ['phlebotinum is not valid hex-encoded data.'] + >>> hex_validator(9)('0123456789abcdef') # doctest: +IGNORE_EXCEPTION_DETAIL + Traceback (most recent call last): + ... + ValidationError: ['0123456789abcdef does not represent exactly 9 bytes.'] + """ + + def _validator(value): + try: + if isinstance(value, str): + value = value.encode() + + unhexlify(value) + except Exception: + raise ValidationError("{0} is not valid hex-encoded data.".format(value)) + + if (length > 0) and (len(value) != length * 2): + raise ValidationError("{0} does not represent exactly {1} bytes.".format(value, length)) + + return _validator + + +def random_hex(length=20): + """ + Returns a string of random bytes encoded as hex. + + This uses :func:`os.urandom`, so it should be suitable for generating + cryptographic keys. + + :param int length: The number of (decoded) bytes to return. + + :returns: A string of hex digits. + :rtype: str + + """ + return urandom(length).hex() + + +def random_number_token(length=6): + """ + Returns a string of random digits encoded as string. + + :param int length: The number of digits to return. + + :returns: A string of decimal digits. + :rtype: str + + """ + rand = random.SystemRandom() + + if hasattr(rand, "choices"): + digits = rand.choices(string.digits, k=length) + else: + digits = (rand.choice(string.digits) for i in range(length)) + + return "".join(digits) diff --git a/authentik/stages/authenticator_duo/models.py b/authentik/stages/authenticator_duo/models.py index ecd5a694e..d1dce81f0 100644 --- a/authentik/stages/authenticator_duo/models.py +++ b/authentik/stages/authenticator_duo/models.py @@ -5,7 +5,6 @@ from django.contrib.auth import get_user_model from django.db import models from django.utils.translation import gettext_lazy as _ from django.views import View -from django_otp.models import Device from duo_client.admin import Admin from duo_client.auth import Auth from rest_framework.serializers import BaseSerializer, Serializer @@ -14,6 +13,7 @@ from authentik.core.types import UserSettingSerializer from authentik.flows.models import ConfigurableStage, FriendlyNamedStage, Stage from authentik.lib.models import SerializerModel from authentik.lib.utils.http import authentik_user_agent +from authentik.stages.authenticator.models import Device class AuthenticatorDuoStage(ConfigurableStage, FriendlyNamedStage, Stage): diff --git a/authentik/stages/authenticator_sms/models.py b/authentik/stages/authenticator_sms/models.py index 65e842d55..7a9658964 100644 --- a/authentik/stages/authenticator_sms/models.py +++ b/authentik/stages/authenticator_sms/models.py @@ -6,7 +6,6 @@ from django.contrib.auth import get_user_model from django.db import models from django.utils.translation import gettext_lazy as _ from django.views import View -from django_otp.models import SideChannelDevice from requests.exceptions import RequestException from rest_framework.exceptions import ValidationError from rest_framework.serializers import BaseSerializer @@ -21,6 +20,7 @@ from authentik.flows.models import ConfigurableStage, FriendlyNamedStage, Stage from authentik.lib.models import SerializerModel from authentik.lib.utils.errors import exception_to_string from authentik.lib.utils.http import get_http_session +from authentik.stages.authenticator.models import SideChannelDevice LOGGER = get_logger() diff --git a/authentik/stages/authenticator_static/api.py b/authentik/stages/authenticator_static/api.py index d009b9feb..d9f474eb1 100644 --- a/authentik/stages/authenticator_static/api.py +++ b/authentik/stages/authenticator_static/api.py @@ -1,6 +1,5 @@ """AuthenticatorStaticStage API Views""" from django_filters.rest_framework import DjangoFilterBackend -from django_otp.plugins.otp_static.models import StaticDevice, StaticToken from rest_framework import mixins from rest_framework.filters import OrderingFilter, SearchFilter from rest_framework.permissions import IsAdminUser @@ -10,7 +9,11 @@ from rest_framework.viewsets import GenericViewSet, ModelViewSet from authentik.api.authorization import OwnerFilter, OwnerPermissions from authentik.core.api.used_by import UsedByMixin from authentik.flows.api.stages import StageSerializer -from authentik.stages.authenticator_static.models import AuthenticatorStaticStage +from authentik.stages.authenticator_static.models import ( + AuthenticatorStaticStage, + StaticDevice, + StaticToken, +) class AuthenticatorStaticStageSerializer(StageSerializer): diff --git a/authentik/stages/authenticator_static/migrations/0008_initial.py b/authentik/stages/authenticator_static/migrations/0008_initial.py new file mode 100644 index 000000000..44257f5db --- /dev/null +++ b/authentik/stages/authenticator_static/migrations/0008_initial.py @@ -0,0 +1,70 @@ +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ( + "authentik_stages_authenticator_static", + "0007_authenticatorstaticstage_token_length_and_more", + ), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="StaticDevice", + fields=[ + ( + "id", + models.AutoField( + verbose_name="ID", serialize=False, auto_created=True, primary_key=True + ), + ), + ( + "name", + models.CharField( + help_text="The human-readable name of this device.", max_length=64 + ), + ), + ( + "confirmed", + models.BooleanField(default=True, help_text="Is this device ready for use?"), + ), + ( + "user", + models.ForeignKey( + help_text="The user that this device belongs to.", + to=settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + ), + ), + ], + options={ + "abstract": False, + }, + bases=(models.Model,), + ), + migrations.CreateModel( + name="StaticToken", + fields=[ + ( + "id", + models.AutoField( + verbose_name="ID", serialize=False, auto_created=True, primary_key=True + ), + ), + ("token", models.CharField(max_length=16, db_index=True)), + ( + "device", + models.ForeignKey( + related_name="token_set", + to="authentik_stages_authenticator_static.staticdevice", + on_delete=models.CASCADE, + ), + ), + ], + options={}, + bases=(models.Model,), + ), + ] diff --git a/authentik/stages/authenticator_static/migrations/0009_throttling.py b/authentik/stages/authenticator_static/migrations/0009_throttling.py new file mode 100644 index 000000000..17690de2e --- /dev/null +++ b/authentik/stages/authenticator_static/migrations/0009_throttling.py @@ -0,0 +1,33 @@ +# Generated by Django 3.0.5 on 2020-04-16 13:41 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("authentik_stages_authenticator_static", "0008_initial"), + ] + + operations = [ + migrations.AddField( + model_name="staticdevice", + name="throttling_failure_count", + field=models.PositiveIntegerField( + default=0, help_text="Number of successive failed attempts." + ), + ), + migrations.AddField( + model_name="staticdevice", + name="throttling_failure_timestamp", + field=models.DateTimeField( + blank=True, + default=None, + help_text="A timestamp of the last failed verification attempt. Null if last attempt succeeded.", + null=True, + ), + ), + migrations.AlterModelOptions( + name="staticdevice", + options={"verbose_name": "Static device", "verbose_name_plural": "Static devices"}, + ), + ] diff --git a/authentik/stages/authenticator_static/models.py b/authentik/stages/authenticator_static/models.py index 05511c489..ac8b55b08 100644 --- a/authentik/stages/authenticator_static/models.py +++ b/authentik/stages/authenticator_static/models.py @@ -1,6 +1,9 @@ """Static Authenticator models""" +from base64 import b32encode +from os import urandom from typing import Optional +from django.conf import settings from django.db import models from django.utils.translation import gettext_lazy as _ from django.views import View @@ -8,6 +11,8 @@ from rest_framework.serializers import BaseSerializer from authentik.core.types import UserSettingSerializer from authentik.flows.models import ConfigurableStage, FriendlyNamedStage, Stage +from authentik.lib.models import SerializerModel +from authentik.stages.authenticator.models import Device, ThrottlingMixin class AuthenticatorStaticStage(ConfigurableStage, FriendlyNamedStage, Stage): @@ -46,3 +51,76 @@ class AuthenticatorStaticStage(ConfigurableStage, FriendlyNamedStage, Stage): class Meta: verbose_name = _("Static Authenticator Stage") verbose_name_plural = _("Static Authenticator Stages") + + +class StaticDevice(SerializerModel, ThrottlingMixin, Device): + """ + A static :class:`~authentik.stages.authenticator.models.Device` simply consists of random + tokens shared by the database and the user. + + These are frequently used as emergency tokens in case a user's normal + device is lost or unavailable. They can be consumed in any order; each + token will be removed from the database as soon as it is used. + + This model has no fields of its own, but serves as a container for + :class:`StaticToken` objects. + + .. attribute:: token_set + + The RelatedManager for our tokens. + + """ + + @property + def serializer(self) -> type[BaseSerializer]: + from authentik.stages.authenticator_static.api import StaticDeviceSerializer + + return StaticDeviceSerializer + + def get_throttle_factor(self): + return getattr(settings, "OTP_STATIC_THROTTLE_FACTOR", 1) + + def verify_token(self, token): + verify_allowed, _ = self.verify_is_allowed() + if verify_allowed: + match = self.token_set.filter(token=token).first() + if match is not None: + match.delete() + self.throttle_reset() + else: + self.throttle_increment() + else: + match = None + + return match is not None + + class Meta(Device.Meta): + verbose_name = _("Static device") + verbose_name_plural = _("Static devices") + + +class StaticToken(models.Model): + """ + A single token belonging to a :class:`StaticDevice`. + + .. attribute:: device + + *ForeignKey*: A foreign key to :class:`StaticDevice`. + + .. attribute:: token + + *CharField*: A random string up to 16 characters. + """ + + device = models.ForeignKey(StaticDevice, related_name="token_set", on_delete=models.CASCADE) + token = models.CharField(max_length=16, db_index=True) + + @staticmethod + def random_token(): + """ + Returns a new random string that can be used as a static token. + + :rtype: bytes + + """ + return b32encode(urandom(5)).decode("utf-8").lower() diff --git a/authentik/stages/authenticator_static/settings.py b/authentik/stages/authenticator_static/settings.py deleted file mode 100644 index 24b347ccc..000000000 --- a/authentik/stages/authenticator_static/settings.py +++ /dev/null @@ -1,5 +0,0 @@ -"""Static Authenticator settings""" - -INSTALLED_APPS = [ - "django_otp.plugins.otp_static", -] diff --git a/authentik/stages/authenticator_static/signals.py b/authentik/stages/authenticator_static/signals.py index 81f2ac14d..5b0428eef 100644 --- a/authentik/stages/authenticator_static/signals.py +++ b/authentik/stages/authenticator_static/signals.py @@ -1,9 +1,9 @@ """totp authenticator signals""" from django.db.models.signals import pre_delete from django.dispatch import receiver -from django_otp.plugins.otp_static.models import StaticDevice from authentik.events.models import Event +from authentik.stages.authenticator_static.models import StaticDevice @receiver(pre_delete, sender=StaticDevice) diff --git a/authentik/stages/authenticator_static/stage.py b/authentik/stages/authenticator_static/stage.py index 8df9ab4b5..47efbfad7 100644 --- a/authentik/stages/authenticator_static/stage.py +++ b/authentik/stages/authenticator_static/stage.py @@ -1,12 +1,15 @@ """Static OTP Setup stage""" from django.http import HttpRequest, HttpResponse -from django_otp.plugins.otp_static.models import StaticDevice, StaticToken from rest_framework.fields import CharField, ListField from authentik.flows.challenge import ChallengeResponse, ChallengeTypes, WithUserInfoChallenge from authentik.flows.stage import ChallengeStageView from authentik.lib.generators import generate_id -from authentik.stages.authenticator_static.models import AuthenticatorStaticStage +from authentik.stages.authenticator_static.models import ( + AuthenticatorStaticStage, + StaticDevice, + StaticToken, +) SESSION_STATIC_DEVICE = "static_device" SESSION_STATIC_TOKENS = "static_device_tokens" diff --git a/authentik/stages/authenticator_static/tests.py b/authentik/stages/authenticator_static/tests.py index 763cfd90c..92e40f752 100644 --- a/authentik/stages/authenticator_static/tests.py +++ b/authentik/stages/authenticator_static/tests.py @@ -1,9 +1,13 @@ """Test Static API""" +from django.test.utils import override_settings from django.urls import reverse -from django_otp.plugins.otp_static.models import StaticDevice from rest_framework.test import APITestCase from authentik.core.models import User +from authentik.core.tests.utils import create_test_admin_user +from authentik.lib.generators import generate_id +from authentik.stages.authenticator.tests import TestCase, ThrottlingTestMixin +from authentik.stages.authenticator_static.models import StaticDevice class AuthenticatorStaticStageTests(APITestCase): @@ -18,3 +22,42 @@ class AuthenticatorStaticStageTests(APITestCase): reverse("authentik_api:staticdevice-detail", kwargs={"pk": dev.pk}) ) self.assertEqual(response.status_code, 204) + + +class DeviceTest(TestCase): + """A few generic tests to get us started.""" + + def setUp(self): + self.user = create_test_admin_user("alice") + + def test_str(self): + """Test __str__ of model""" + device = StaticDevice.objects.create(user=self.user, name="Device") + + str(device) + + def test_str_unpopulated(self): + """Test __str__ of model""" + device = StaticDevice() + + str(device) + + +@override_settings( + OTP_STATIC_THROTTLE_FACTOR=1, +) +class ThrottlingTestCase(ThrottlingTestMixin, TestCase): + """Test static device throttling""" + + def setUp(self): + user = create_test_admin_user("alice") + self.device = user.staticdevice_set.create() + self.device.token_set.create(token=generate_id(length=16)) + self.device.token_set.create(token=generate_id(length=16)) + self.device.token_set.create(token=generate_id(length=16)) + + def valid_token(self): + return self.device.token_set.first().token + + def invalid_token(self): + return "bogus" diff --git a/authentik/stages/authenticator_totp/api.py b/authentik/stages/authenticator_totp/api.py index 61f596843..df1a379f4 100644 --- a/authentik/stages/authenticator_totp/api.py +++ b/authentik/stages/authenticator_totp/api.py @@ -1,6 +1,5 @@ """AuthenticatorTOTPStage API Views""" from django_filters.rest_framework.backends import DjangoFilterBackend -from django_otp.plugins.otp_totp.models import TOTPDevice from rest_framework import mixins from rest_framework.filters import OrderingFilter, SearchFilter from rest_framework.permissions import IsAdminUser @@ -10,7 +9,7 @@ from rest_framework.viewsets import GenericViewSet, ModelViewSet from authentik.api.authorization import OwnerFilter, OwnerPermissions from authentik.core.api.used_by import UsedByMixin from authentik.flows.api.stages import StageSerializer -from authentik.stages.authenticator_totp.models import AuthenticatorTOTPStage +from authentik.stages.authenticator_totp.models import AuthenticatorTOTPStage, TOTPDevice class AuthenticatorTOTPStageSerializer(StageSerializer): diff --git a/authentik/stages/authenticator_totp/migrations/0008_initial.py b/authentik/stages/authenticator_totp/migrations/0008_initial.py new file mode 100644 index 000000000..3f530d0ec --- /dev/null +++ b/authentik/stages/authenticator_totp/migrations/0008_initial.py @@ -0,0 +1,98 @@ +from django.conf import settings +from django.db import migrations, models + +import authentik.stages.authenticator_totp.models + + +class Migration(migrations.Migration): + dependencies = [ + ("authentik_stages_authenticator_totp", "0007_authenticatortotpstage_friendly_name"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="TOTPDevice", + fields=[ + ( + "id", + models.AutoField( + verbose_name="ID", serialize=False, auto_created=True, primary_key=True + ), + ), + ( + "name", + models.CharField( + help_text="The human-readable name of this device.", max_length=64 + ), + ), + ( + "confirmed", + models.BooleanField(default=True, help_text="Is this device ready for use?"), + ), + ( + "key", + models.CharField( + default=authentik.stages.authenticator_totp.models.default_key, + help_text="A hex-encoded secret key of up to 40 bytes.", + max_length=80, + validators=[authentik.stages.authenticator_totp.models.key_validator], + ), + ), + ( + "step", + models.PositiveSmallIntegerField( + default=30, help_text="The time step in seconds." + ), + ), + ( + "t0", + models.BigIntegerField( + default=0, help_text="The Unix time at which to begin counting steps." + ), + ), + ( + "digits", + models.PositiveSmallIntegerField( + default=6, + help_text="The number of digits to expect in a token.", + choices=[(6, 6), (8, 8)], + ), + ), + ( + "tolerance", + models.PositiveSmallIntegerField( + default=1, + help_text="The number of time steps in the past or future to allow.", + ), + ), + ( + "drift", + models.SmallIntegerField( + default=0, + help_text="The number of time steps the prover is known to deviate from our clock.", + ), + ), + ( + "last_t", + models.BigIntegerField( + default=-1, + help_text="The t value of the latest verified token. The next token must be at a higher time step.", + ), + ), + ( + "user", + models.ForeignKey( + help_text="The user that this device belongs to.", + to=settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + ), + ), + ], + options={ + "abstract": False, + "verbose_name": "TOTP device", + }, + bases=(models.Model,), + ), + ] diff --git a/authentik/stages/authenticator_totp/migrations/0009_auto_20190420_0723.py b/authentik/stages/authenticator_totp/migrations/0009_auto_20190420_0723.py new file mode 100644 index 000000000..00a7a385e --- /dev/null +++ b/authentik/stages/authenticator_totp/migrations/0009_auto_20190420_0723.py @@ -0,0 +1,29 @@ +# Generated by Django 2.2 on 2019-04-20 12:23 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("authentik_stages_authenticator_totp", "0008_initial"), + ] + + operations = [ + migrations.AddField( + model_name="totpdevice", + name="throttling_failure_count", + field=models.PositiveIntegerField( + default=0, help_text="Number of successive failed attempts." + ), + ), + migrations.AddField( + model_name="totpdevice", + name="throttling_failure_timestamp", + field=models.DateTimeField( + blank=True, + default=None, + help_text="A timestamp of the last failed verification attempt. Null if last attempt succeeded.", + null=True, + ), + ), + ] diff --git a/authentik/stages/authenticator_totp/migrations/0010_alter_totpdevice_key.py b/authentik/stages/authenticator_totp/migrations/0010_alter_totpdevice_key.py new file mode 100644 index 000000000..436eaa38a --- /dev/null +++ b/authentik/stages/authenticator_totp/migrations/0010_alter_totpdevice_key.py @@ -0,0 +1,28 @@ +# Generated by Django 4.2.4 on 2023-09-03 00:55 + +from django.db import migrations, models + +import authentik.stages.authenticator_totp.models + + +class Migration(migrations.Migration): + dependencies = [ + ("authentik_stages_authenticator_totp", "0009_auto_20190420_0723"), + ] + + operations = [ + migrations.AlterField( + model_name="totpdevice", + name="key", + field=models.CharField( + default=authentik.stages.authenticator_totp.models.default_key, + help_text="A hex-encoded secret key of up to 40 bytes.", + max_length=80, + validators=[authentik.stages.authenticator_totp.models.key_validator], + ), + ), + migrations.AlterModelOptions( + name="totpdevice", + options={"verbose_name": "TOTP device", "verbose_name_plural": "TOTP devices"}, + ), + ] diff --git a/authentik/stages/authenticator_totp/models.py b/authentik/stages/authenticator_totp/models.py index 03c84b553..6828a8e2e 100644 --- a/authentik/stages/authenticator_totp/models.py +++ b/authentik/stages/authenticator_totp/models.py @@ -1,6 +1,11 @@ """OTP Time-based models""" +import time +from base64 import b32encode +from binascii import unhexlify from typing import Optional +from urllib.parse import quote, urlencode +from django.conf import settings from django.db import models from django.utils.translation import gettext_lazy as _ from django.views import View @@ -8,10 +13,14 @@ from rest_framework.serializers import BaseSerializer from authentik.core.types import UserSettingSerializer from authentik.flows.models import ConfigurableStage, FriendlyNamedStage, Stage +from authentik.lib.models import SerializerModel +from authentik.stages.authenticator.models import Device, ThrottlingMixin +from authentik.stages.authenticator.oath import TOTP +from authentik.stages.authenticator.util import hex_validator, random_hex class TOTPDigits(models.IntegerChoices): - """OTP Time Digits""" + """OTP Time Digits""" SIX = 6, _("6 digits, widely compatible") EIGHT = 8, _("8 digits, not compatible with apps like Google Authenticator") @@ -52,3 +61,185 @@ class AuthenticatorTOTPStage(ConfigurableStage, FriendlyNamedStage, Stage): class Meta: verbose_name = _("TOTP Authenticator Setup Stage") verbose_name_plural = _("TOTP Authenticator Setup Stages") + + +def default_key(): + """Default TOTP Device key""" + return random_hex(20) + + +def key_validator(value): + """Validate totp key""" + return hex_validator()(value) + + +class TOTPDevice(SerializerModel, ThrottlingMixin, Device): + """ + A generic TOTP :class:`~authentik.stages.authenticator.models.Device`. The model fields mostly + correspond to the arguments to :func:`authentik.stages.authenticator.oath.totp`. They all have + sensible defaults, including the key, which is randomly generated. + + .. attribute:: key + + *CharField*: A hex-encoded secret key of up to 40 bytes. (Default: 20 + random bytes) + + .. attribute:: step + + *PositiveSmallIntegerField*: The time step in seconds. (Default: 30) + + .. attribute:: t0 + + *BigIntegerField*: The Unix time at which to begin counting steps. + (Default: 0) + + .. attribute:: digits + + *PositiveSmallIntegerField*: The number of digits to expect in a token + (6 or 8). (Default: 6) + + .. attribute:: tolerance + + *PositiveSmallIntegerField*: The number of time steps in the past or + future to allow. For example, if this is 1, we'll accept any of three + tokens: the current one, the previous one, and the next one. (Default: + 1) + + .. attribute:: drift + + *SmallIntegerField*: The number of time steps the prover is known to + deviate from our clock. If :setting:`OTP_TOTP_SYNC` is ``True``, we'll + update this any time we match a token that is not the current one. + (Default: 0) + + .. attribute:: last_t + + *BigIntegerField*: The time step of the last verified token. To avoid + verifying the same token twice, this will be updated on each successful + verification. Only tokens at a higher time step will be verified + subsequently. (Default: -1) + + """ + + key = models.CharField( + max_length=80, + validators=[key_validator], + default=default_key, + help_text="A hex-encoded secret key of up to 40 bytes.", + ) + step = models.PositiveSmallIntegerField(default=30, help_text="The time step in seconds.") + t0 = models.BigIntegerField( + default=0, help_text="The Unix time at which to begin counting steps." + ) + digits = models.PositiveSmallIntegerField( + choices=[(6, 6), (8, 8)], + default=6, + help_text="The number of digits to expect in a token.", + ) + tolerance = models.PositiveSmallIntegerField( + default=1, help_text="The number of time steps in the past or future to allow." + ) + drift = models.SmallIntegerField( + default=0, + help_text="The number of time steps the prover is known to deviate from our clock.", + ) + last_t = models.BigIntegerField( + default=-1, + help_text=( + "The t value of the latest verified token. " + "The next token must be at a higher time step." + ), + ) + + @property + def serializer(self) -> type[BaseSerializer]: + from authentik.stages.authenticator_totp.api import TOTPDeviceSerializer + + return TOTPDeviceSerializer + + @property + def bin_key(self): + """ + The secret key as a binary string. + """ + return unhexlify(self.key.encode()) + + def verify_token(self, token): + otp_totp_sync = getattr(settings, "OTP_TOTP_SYNC", True) + + verify_allowed, _ = self.verify_is_allowed() + if not verify_allowed: + return False + + try: + token = int(token) + except ValueError: + verified = False + else: + key = self.bin_key + + totp = TOTP(key, self.step, self.t0, self.digits, self.drift) + totp.time = time.time() + + verified = totp.verify(token, self.tolerance, self.last_t + 1) + if verified: + self.last_t = totp.t() + if otp_totp_sync: + self.drift = totp.drift + self.throttle_reset(commit=False) + self.save() + + if not verified: + self.throttle_increment(commit=True) + + return verified + + def get_throttle_factor(self): + return getattr(settings, "OTP_TOTP_THROTTLE_FACTOR", 1) + + @property + def config_url(self): + """ + A URL for configuring Google Authenticator or similar. + + See https://github.com/google/google-authenticator/wiki/Key-Uri-Format. + The issuer is taken from :setting:`OTP_TOTP_ISSUER`, if available. + The image (for e.g. FreeOTP) is taken from :setting:`OTP_TOTP_IMAGE`, if available. + + """ + label = str(self.user.username) + params = { + "secret": b32encode(self.bin_key), + "algorithm": "SHA1", + "digits": self.digits, + "period": self.step, + } + urlencoded_params = urlencode(params) + + issuer = self._read_str_from_settings("OTP_TOTP_ISSUER") + if issuer: + issuer = issuer.replace(":", "") + label = "{}:{}".format(issuer, label) + urlencoded_params += "&issuer={}".format( + quote(issuer) + ) # encode issuer as per RFC 3986, not quote_plus + + image = self._read_str_from_settings("OTP_TOTP_IMAGE") + if image: + urlencoded_params += "&image={}".format(quote(image, safe=":/")) + + url = "otpauth://totp/{}?{}".format(quote(label), urlencoded_params) + + return url + + def _read_str_from_settings(self, key): + val = getattr(settings, key, None) + if callable(val): + val = val(self) + if isinstance(val, str) and (val != ""): + return val + return None + + class Meta(Device.Meta): + verbose_name = _("TOTP device") + verbose_name_plural = _("TOTP devices") diff --git a/authentik/stages/authenticator_totp/settings.py b/authentik/stages/authenticator_totp/settings.py index 779c43972..dbecf3f20 100644 --- a/authentik/stages/authenticator_totp/settings.py +++ b/authentik/stages/authenticator_totp/settings.py @@ -1,6 +1,3 @@ """OTP Time""" -INSTALLED_APPS = [ - "django_otp.plugins.otp_totp", -] OTP_TOTP_ISSUER = "__to_replace__" diff --git a/authentik/stages/authenticator_totp/stage.py b/authentik/stages/authenticator_totp/stage.py index 80ad8a075..d3b03a34c 100644 --- a/authentik/stages/authenticator_totp/stage.py +++ b/authentik/stages/authenticator_totp/stage.py @@ -4,7 +4,6 @@ from urllib.parse import quote from django.http import HttpRequest, HttpResponse from django.http.request import QueryDict from django.utils.translation import gettext_lazy as _ -from django_otp.plugins.otp_totp.models import TOTPDevice from rest_framework.fields import CharField, IntegerField from rest_framework.serializers import ValidationError @@ -15,7 +14,7 @@ from authentik.flows.challenge import ( WithUserInfoChallenge, ) from authentik.flows.stage import ChallengeStageView -from authentik.stages.authenticator_totp.models import AuthenticatorTOTPStage +from authentik.stages.authenticator_totp.models import AuthenticatorTOTPStage, TOTPDevice from authentik.stages.authenticator_totp.settings import OTP_TOTP_ISSUER SESSION_TOTP_DEVICE = "totp_device" diff --git a/authentik/stages/authenticator_totp/tests.py b/authentik/stages/authenticator_totp/tests.py index f89745bda..8ee7577ad 100644 --- a/authentik/stages/authenticator_totp/tests.py +++ b/authentik/stages/authenticator_totp/tests.py @@ -1,9 +1,15 @@ """Test TOTP API""" +from time import time +from urllib.parse import parse_qs, urlsplit + +from django.test.utils import override_settings from django.urls import reverse -from django_otp.plugins.otp_totp.models import TOTPDevice from rest_framework.test import APITestCase from authentik.core.models import User +from authentik.core.tests.utils import create_test_admin_user +from authentik.stages.authenticator.tests import TestCase, ThrottlingTestMixin +from authentik.stages.authenticator_totp.models import TOTPDevice class AuthenticatorTOTPStage(APITestCase): @@ -18,3 +24,180 @@ class AuthenticatorTOTPStage(APITestCase): reverse("authentik_api:totpdevice-detail", kwargs={"pk": dev.pk}) ) self.assertEqual(response.status_code, 204) + + +class TOTPDeviceMixin: + """ + A TestCase helper that gives us a TOTPDevice to work with. + """ + + # The next ten tokens + tokens = [ + 179225, + 656163, + 839400, + 154567, + 346912, + 471576, + 45675, + 101397, + 491039, + 784503, + ] + + # pylint: disable=invalid-name + def setUp(self): + """ + Create a device at the fourth time step. The current token is 154567. + """ + self.alice = create_test_admin_user("alice", email="alice@example.com") + self.device = self.alice.totpdevice_set.create( + key="2a2bbba1092ffdd25a328ad1a0a5f5d61d7aacc4", + step=30, + t0=int(time() - (30 * 3)), + digits=6, + tolerance=0, + drift=0, + ) + + +@override_settings( + OTP_TOTP_SYNC=False, + OTP_TOTP_THROTTLE_FACTOR=0, +) +class TOTPTest(TOTPDeviceMixin, TestCase): + """TOTP tests""" + + def test_default_key(self): + """Ensure default_key is valid""" + device = self.alice.totpdevice_set.create() + + # Make sure we can decode the key. + _ = device.bin_key + + def test_single(self): + """Test single token""" + results = [self.device.verify_token(token) for token in self.tokens] + + self.assertEqual(results, [False] * 3 + [True] + [False] * 6) + + def test_tolerance(self): + """Test tolerance""" + self.device.tolerance = 1 + results = [self.device.verify_token(token) for token in self.tokens] + + self.assertEqual(results, [False] * 2 + [True] * 3 + [False] * 5) + + def test_drift(self): + """Test drift""" + self.device.tolerance = 1 + self.device.drift = -1 + results = [self.device.verify_token(token) for token in self.tokens] + + self.assertEqual(results, [False] * 1 + [True] * 3 + [False] * 6) + + def test_sync_drift(self): + """Test sync drift""" + self.device.tolerance = 2 + with self.settings(OTP_TOTP_SYNC=True): + valid = self.device.verify_token(self.tokens[5]) + + self.assertTrue(valid) + self.assertEqual(self.device.drift, 2) + + def test_no_reuse(self): + """Test reuse""" + verified1 = self.device.verify_token(self.tokens[3]) + verified2 = self.device.verify_token(self.tokens[3]) + + self.assertEqual(self.device.last_t, 3) + self.assertTrue(verified1) + self.assertFalse(verified2) + + def test_config_url(self): + """Test config_url""" + with override_settings(OTP_TOTP_ISSUER=None): + url = self.device.config_url + + parsed = urlsplit(url) + params = parse_qs(parsed.query) + + self.assertEqual(parsed.scheme, "otpauth") + self.assertEqual(parsed.netloc, "totp") + self.assertEqual(parsed.path, "/alice") + self.assertIn("secret", params) + self.assertNotIn("issuer", params) + + def test_config_url_issuer(self): + """Test config_url issuer""" + with override_settings(OTP_TOTP_ISSUER="example.com"): + url = self.device.config_url + + parsed = urlsplit(url) + params = parse_qs(parsed.query) + + self.assertEqual(parsed.scheme, "otpauth") + self.assertEqual(parsed.netloc, "totp") + self.assertEqual(parsed.path, "/example.com%3Aalice") + self.assertIn("secret", params) + self.assertIn("issuer", params) + self.assertEqual(params["issuer"][0], "example.com") + + def test_config_url_issuer_spaces(self): + """Test config_url issuer with spaces""" + with override_settings(OTP_TOTP_ISSUER="Very Trustworthy Source"): + url = self.device.config_url + + parsed = urlsplit(url) + params = parse_qs(parsed.query) + + self.assertEqual(parsed.scheme, "otpauth") + self.assertEqual(parsed.netloc, "totp") + self.assertEqual(parsed.path, "/Very%20Trustworthy%20Source%3Aalice") + self.assertIn("secret", params) + self.assertIn("issuer", params) + self.assertEqual(params["issuer"][0], "Very Trustworthy Source") + + def test_config_url_issuer_method(self): + """Test config_url issuer method""" + with override_settings(OTP_TOTP_ISSUER=lambda d: d.user.email): + url = self.device.config_url + + parsed = urlsplit(url) + params = parse_qs(parsed.query) + + self.assertEqual(parsed.scheme, "otpauth") + self.assertEqual(parsed.netloc, "totp") + self.assertEqual(parsed.path, "/alice%40example.com%3Aalice") + self.assertIn("secret", params) + self.assertIn("issuer", params) + self.assertEqual(params["issuer"][0], "alice@example.com") + + def test_config_url_image(self): + """Test config_url with image""" + image_url = "https://test.invalid/square.png" + + with override_settings(OTP_TOTP_ISSUER=None, OTP_TOTP_IMAGE=image_url): + url = self.device.config_url + + parsed = urlsplit(url) + params = parse_qs(parsed.query) + + self.assertEqual(parsed.scheme, "otpauth") + self.assertEqual(parsed.netloc, "totp") + self.assertEqual(parsed.path, "/alice") + self.assertIn("secret", params) + self.assertEqual(params["image"][0], image_url) + + +@override_settings( + OTP_TOTP_THROTTLE_FACTOR=1, +) +class ThrottlingTestCase(TOTPDeviceMixin, ThrottlingTestMixin, TestCase): + """Test TOTP Throttling""" + + def valid_token(self): + return self.tokens[3] + + def invalid_token(self): + return -1 diff --git a/authentik/stages/authenticator_validate/challenge.py b/authentik/stages/authenticator_validate/challenge.py index c8d86bf10..3cd43c07b 100644 --- a/authentik/stages/authenticator_validate/challenge.py +++ b/authentik/stages/authenticator_validate/challenge.py @@ -7,8 +7,6 @@ from django.http.response import Http404 from django.shortcuts import get_object_or_404 from django.utils.translation import gettext as __ from django.utils.translation import gettext_lazy as _ -from django_otp import match_token -from django_otp.models import Device from rest_framework.fields import CharField, JSONField from rest_framework.serializers import ValidationError from structlog.stdlib import get_logger @@ -25,6 +23,8 @@ from authentik.events.models import Event, EventAction from authentik.flows.stage import StageView from authentik.flows.views.executor import SESSION_KEY_APPLICATION_PRE from authentik.lib.utils.http import get_client_ip +from authentik.stages.authenticator import match_token +from authentik.stages.authenticator.models import Device from authentik.stages.authenticator_duo.models import AuthenticatorDuoStage, DuoDevice from authentik.stages.authenticator_sms.models import SMSDevice from authentik.stages.authenticator_validate.models import AuthenticatorValidateStage, DeviceClasses diff --git a/authentik/stages/authenticator_validate/settings.py b/authentik/stages/authenticator_validate/settings.py deleted file mode 100644 index 34902a427..000000000 --- a/authentik/stages/authenticator_validate/settings.py +++ /dev/null @@ -1,4 +0,0 @@ -"""OTP Validate stage settings""" -INSTALLED_APPS = [ - "django_otp", -] diff --git a/authentik/stages/authenticator_validate/stage.py b/authentik/stages/authenticator_validate/stage.py index ff9a1dac6..4b88041e5 100644 --- a/authentik/stages/authenticator_validate/stage.py +++ b/authentik/stages/authenticator_validate/stage.py @@ -5,8 +5,6 @@ from typing import Optional from django.conf import settings from django.http import HttpRequest, HttpResponse -from django_otp import devices_for_user -from django_otp.models import Device from jwt import PyJWTError, decode, encode from rest_framework.fields import CharField, IntegerField, JSONField, ListField, UUIDField from rest_framework.serializers import ValidationError @@ -21,6 +19,8 @@ from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER from authentik.flows.stage import ChallengeStageView from authentik.lib.utils.time import timedelta_from_string from authentik.root.install_id import get_install_id +from authentik.stages.authenticator import devices_for_user +from authentik.stages.authenticator.models import Device from authentik.stages.authenticator_sms.models import SMSDevice from authentik.stages.authenticator_validate.challenge import ( DeviceChallenge, diff --git a/authentik/stages/authenticator_validate/tests/test_totp.py b/authentik/stages/authenticator_validate/tests/test_totp.py index e5ecb7fbc..5873a4f69 100644 --- a/authentik/stages/authenticator_validate/tests/test_totp.py +++ b/authentik/stages/authenticator_validate/tests/test_totp.py @@ -5,8 +5,6 @@ from time import sleep from django.test.client import RequestFactory from django.urls.base import reverse -from django_otp.oath import TOTP -from django_otp.plugins.otp_totp.models import TOTPDevice from jwt import encode from rest_framework.exceptions import ValidationError @@ -17,6 +15,8 @@ from authentik.flows.tests import FlowTestCase from authentik.flows.views.executor import FlowExecutorView from authentik.lib.generators import generate_id from authentik.root.install_id import get_install_id +from authentik.stages.authenticator.oath import TOTP +from authentik.stages.authenticator_totp.models import TOTPDevice from authentik.stages.authenticator_validate.challenge import ( get_challenge_for_device, validate_challenge_code, diff --git a/authentik/stages/authenticator_webauthn/models.py b/authentik/stages/authenticator_webauthn/models.py index e498f2a0b..5f4768195 100644 --- a/authentik/stages/authenticator_webauthn/models.py +++ b/authentik/stages/authenticator_webauthn/models.py @@ -6,7 +6,6 @@ from django.db import models from django.utils.timezone import now from django.utils.translation import gettext_lazy as _ from django.views import View -from django_otp.models import Device from rest_framework.serializers import BaseSerializer, Serializer from webauthn.helpers.base64url_to_bytes import base64url_to_bytes from webauthn.helpers.structs import PublicKeyCredentialDescriptor @@ -14,6 +13,7 @@ from webauthn.helpers.structs import PublicKeyCredentialDescriptor from authentik.core.types import UserSettingSerializer from authentik.flows.models import ConfigurableStage, FriendlyNamedStage, Stage from authentik.lib.models import SerializerModel +from authentik.stages.authenticator.models import Device class UserVerification(models.TextChoices): diff --git a/blueprints/schema.json b/blueprints/schema.json index 5b43dfdaf..0c925724c 100644 --- a/blueprints/schema.json +++ b/blueprints/schema.json @@ -1625,6 +1625,42 @@ } } }, + { + "type": "object", + "required": [ + "model", + "identifiers" + ], + "properties": { + "model": { + "const": "authentik_stages_authenticator_static.staticdevice" + }, + "id": { + "type": "string" + }, + "state": { + "type": "string", + "enum": [ + "absent", + "present", + "created" + ], + "default": "present" + }, + "conditions": { + "type": "array", + "items": { + "type": "boolean" + } + }, + "attrs": { + "$ref": "#/$defs/model_authentik_stages_authenticator_static.staticdevice" + }, + "identifiers": { + "$ref": "#/$defs/model_authentik_stages_authenticator_static.staticdevice" + } + } + }, { "type": "object", "required": [ @@ -1661,6 +1697,42 @@ } } }, + { + "type": "object", + "required": [ + "model", + "identifiers" + ], + "properties": { + "model": { + "const": "authentik_stages_authenticator_totp.totpdevice" + }, + "id": { + "type": "string" + }, + "state": { + "type": "string", + "enum": [ + "absent", + "present", + "created" + ], + "default": "present" + }, + "conditions": { + "type": "array", + "items": { + "type": "boolean" + } + }, + "attrs": { + "$ref": "#/$defs/model_authentik_stages_authenticator_totp.totpdevice" + }, + "identifiers": { + "$ref": "#/$defs/model_authentik_stages_authenticator_totp.totpdevice" + } + } + }, { "type": "object", "required": [ @@ -3232,6 +3304,7 @@ "authentik.sources.oauth", "authentik.sources.plex", "authentik.sources.saml", + "authentik.stages.authenticator", "authentik.stages.authenticator_duo", "authentik.stages.authenticator_sms", "authentik.stages.authenticator_static", @@ -3310,7 +3383,9 @@ "authentik_stages_authenticator_sms.authenticatorsmsstage", "authentik_stages_authenticator_sms.smsdevice", "authentik_stages_authenticator_static.authenticatorstaticstage", + "authentik_stages_authenticator_static.staticdevice", "authentik_stages_authenticator_totp.authenticatortotpstage", + "authentik_stages_authenticator_totp.totpdevice", "authentik_stages_authenticator_validate.authenticatorvalidatestage", "authentik_stages_authenticator_webauthn.authenticatewebauthnstage", "authentik_stages_authenticator_webauthn.webauthndevice", @@ -5872,6 +5947,19 @@ }, "required": [] }, + "model_authentik_stages_authenticator_static.staticdevice": { + "type": "object", + "properties": { + "name": { + "type": "string", + "maxLength": 64, + "minLength": 1, + "title": "Name", + "description": "The human-readable name of this device." + } + }, + "required": [] + }, "model_authentik_stages_authenticator_totp.authenticatortotpstage": { "type": "object", "properties": { @@ -5986,6 +6074,19 @@ }, "required": [] }, + "model_authentik_stages_authenticator_totp.totpdevice": { + "type": "object", + "properties": { + "name": { + "type": "string", + "maxLength": 64, + "minLength": 1, + "title": "Name", + "description": "The human-readable name of this device." + } + }, + "required": [] + }, "model_authentik_stages_authenticator_validate.authenticatorvalidatestage": { "type": "object", "properties": { diff --git a/lifecycle/system_migrations/otp_merge.py b/lifecycle/system_migrations/otp_merge.py new file mode 100644 index 000000000..c3908bffa --- /dev/null +++ b/lifecycle/system_migrations/otp_merge.py @@ -0,0 +1,47 @@ +# flake8: noqa +from os import system + +from lifecycle.migrate import BaseMigration + +SQL_STATEMENT = """ +BEGIN TRANSACTION; +DELETE FROM django_migrations WHERE app = 'otp_static'; +DELETE FROM django_migrations WHERE app = 'otp_totp'; +-- Rename tables (static) +ALTER TABLE otp_static_staticdevice RENAME TO authentik_stages_authenticator_static_staticdevice; +ALTER TABLE otp_static_statictoken RENAME TO authentik_stages_authenticator_static_statictoken; +ALTER SEQUENCE otp_static_statictoken_id_seq RENAME TO authentik_stages_authenticator_static_statictoken_id_seq; +ALTER SEQUENCE otp_static_staticdevice_id_seq RENAME TO authentik_stages_authenticator_static_staticdevice_id_seq; +-- Rename tables (totp) +ALTER TABLE otp_totp_totpdevice RENAME TO authentik_stages_authenticator_totp_totpdevice; +ALTER SEQUENCE otp_totp_totpdevice_id_seq RENAME TO authentik_stages_authenticator_totp_totpdevice_id_seq; +COMMIT;""" + + +class Migration(BaseMigration): + def needs_migration(self) -> bool: + self.cur.execute( + "select * from information_schema.tables WHERE table_name='otp_static_staticdevice'" + ) + return bool(self.cur.rowcount) + + def system_crit(self, command): + retval = system(command) # nosec + if retval != 0: + raise Exception("Migration error") + + def run(self): + self.cur.execute(SQL_STATEMENT) + self.con.commit() + self.system_crit( + "./manage.py migrate authentik_stages_authenticator_static 0008_initial --fake" + ) + self.system_crit( + "./manage.py migrate authentik_stages_authenticator_static 0009_throttling --fake" + ) + self.system_crit( + "./manage.py migrate authentik_stages_authenticator_totp 0008_initial --fake" + ) + self.system_crit( + "./manage.py migrate authentik_stages_authenticator_totp 0009_auto_20190420_0723 --fake" + ) diff --git a/poetry.lock b/poetry.lock index 23cb7e227..e121c0f44 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1185,23 +1185,6 @@ files = [ [package.dependencies] Django = ">=3.2" -[[package]] -name = "django-otp" -version = "1.2.2" -description = "A pluggable framework for adding two-factor authentication to Django using one-time passwords." -optional = false -python-versions = ">=3.7" -files = [ - {file = "django_otp-1.2.2-py3-none-any.whl", hash = "sha256:90765d5dac238a719f9550ac05681dd6307f513a81a10b6adb879b4abc6bc1a3"}, - {file = "django_otp-1.2.2.tar.gz", hash = "sha256:007a6354dabb3a1a54574bf73abf045ebbde0bb8734a38e2ed7845ba450f345e"}, -] - -[package.dependencies] -django = ">=3.2" - -[package.extras] -qrcode = ["qrcode"] - [[package]] name = "django-prometheus" version = "2.3.1" @@ -1458,6 +1441,20 @@ prometheus-client = ">=0.8.0" pytz = "*" tornado = ">=5.0.0,<7.0.0" +[[package]] +name = "freezegun" +version = "1.2.2" +description = "Let your Python tests travel through time" +optional = false +python-versions = ">=3.6" +files = [ + {file = "freezegun-1.2.2-py3-none-any.whl", hash = "sha256:ea1b963b993cb9ea195adbd893a48d573fda951b0da64f60883d7e988b606c9f"}, + {file = "freezegun-1.2.2.tar.gz", hash = "sha256:cd22d1ba06941384410cd967d8a99d5ae2442f57dfafeff2fda5de8dc5c05446"}, +] + +[package.dependencies] +python-dateutil = ">=2.7" + [[package]] name = "frozenlist" version = "1.4.0" @@ -4456,4 +4453,4 @@ files = [ [metadata] lock-version = "2.0" python-versions = "^3.11" -content-hash = "8604e4dac9b0dcc55daccab83d4c182981d21201ef9901cf9c1acdc24288f979" +content-hash = "e6b1df989cb5c50609540c1229d05d8458ef1cc343fb5868402db8b7679ad73c" diff --git a/pyproject.toml b/pyproject.toml index 79750f204..801db0131 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -95,7 +95,7 @@ extension-pkg-whitelist = ["lxml", "xmlsec"] # Allow constants to be shorter than normal (and lowercase, for settings.py) const-rgx = "[a-zA-Z0-9_]{1,40}$" -ignored-modules = ["django-otp", "binascii", "socket", "zlib"] +ignored-modules = ["binascii", "socket", "zlib"] generated-members = ["xmlsec.constants.*", "xmlsec.tree.*", "xmlsec.template.*"] ignore = "migrations" max-attributes = 12 @@ -105,7 +105,7 @@ max-branches = 20 DJANGO_SETTINGS_MODULE = "authentik.root.settings" python_files = ["tests.py", "test_*.py", "*_tests.py"] junit_family = "xunit2" -addopts = "-p no:celery --junitxml=unittest.xml" +addopts = "-p no:celery --junitxml=unittest.xml -vv --full-trace --doctest-modules" filterwarnings = [ "ignore:defusedxml.lxml is no longer supported and will be removed in a future release.:DeprecationWarning", "ignore:SelectableGroups dict interface is deprecated. Use select.:DeprecationWarning", @@ -131,7 +131,6 @@ django = "*" django-filter = "*" django-guardian = "*" django-model-utils = "*" -django-otp = "*" django-prometheus = "*" django-redis = "*" djangorestframework = "*" @@ -183,6 +182,7 @@ coverage = { extras = ["toml"], version = "*" } debugpy = "*" django-silk = "*" drf-jsonschema-serializer = "*" +freezegun = "*" importlib-metadata = "*" pdoc = "*" pylint = "*" diff --git a/schema.yml b/schema.yml index 10249bf12..35e5a8780 100644 --- a/schema.yml +++ b/schema.yml @@ -885,7 +885,7 @@ paths: name: id schema: type: integer - description: A unique integer value identifying this static device. + description: A unique integer value identifying this Static device. required: true tags: - authenticators @@ -918,7 +918,7 @@ paths: name: id schema: type: integer - description: A unique integer value identifying this static device. + description: A unique integer value identifying this Static device. required: true tags: - authenticators @@ -957,7 +957,7 @@ paths: name: id schema: type: integer - description: A unique integer value identifying this static device. + description: A unique integer value identifying this Static device. required: true tags: - authenticators @@ -995,7 +995,7 @@ paths: name: id schema: type: integer - description: A unique integer value identifying this static device. + description: A unique integer value identifying this Static device. required: true tags: - authenticators @@ -2030,7 +2030,7 @@ paths: name: id schema: type: integer - description: A unique integer value identifying this static device. + description: A unique integer value identifying this Static device. required: true tags: - authenticators @@ -2063,7 +2063,7 @@ paths: name: id schema: type: integer - description: A unique integer value identifying this static device. + description: A unique integer value identifying this Static device. required: true tags: - authenticators @@ -2102,7 +2102,7 @@ paths: name: id schema: type: integer - description: A unique integer value identifying this static device. + description: A unique integer value identifying this Static device. required: true tags: - authenticators @@ -2140,7 +2140,7 @@ paths: name: id schema: type: integer - description: A unique integer value identifying this static device. + description: A unique integer value identifying this Static device. required: true tags: - authenticators @@ -2170,7 +2170,7 @@ paths: name: id schema: type: integer - description: A unique integer value identifying this static device. + description: A unique integer value identifying this Static device. required: true tags: - authenticators @@ -26693,6 +26693,7 @@ components: - authentik.sources.oauth - authentik.sources.plex - authentik.sources.saml + - authentik.stages.authenticator - authentik.stages.authenticator_duo - authentik.stages.authenticator_sms - authentik.stages.authenticator_static @@ -26742,6 +26743,7 @@ components: * `authentik.sources.oauth` - authentik Sources.OAuth * `authentik.sources.plex` - authentik Sources.Plex * `authentik.sources.saml` - authentik Sources.SAML + * `authentik.stages.authenticator` - authentik Stages.Authenticator * `authentik.stages.authenticator_duo` - authentik Stages.Authenticator.Duo * `authentik.stages.authenticator_sms` - authentik Stages.Authenticator.SMS * `authentik.stages.authenticator_static` - authentik Stages.Authenticator.Static @@ -29476,6 +29478,7 @@ components: * `authentik.sources.oauth` - authentik Sources.OAuth * `authentik.sources.plex` - authentik Sources.Plex * `authentik.sources.saml` - authentik Sources.SAML + * `authentik.stages.authenticator` - authentik Stages.Authenticator * `authentik.stages.authenticator_duo` - authentik Stages.Authenticator.Duo * `authentik.stages.authenticator_sms` - authentik Stages.Authenticator.SMS * `authentik.stages.authenticator_static` - authentik Stages.Authenticator.Static @@ -29550,7 +29553,9 @@ components: * `authentik_stages_authenticator_sms.authenticatorsmsstage` - SMS Authenticator Setup Stage * `authentik_stages_authenticator_sms.smsdevice` - SMS Device * `authentik_stages_authenticator_static.authenticatorstaticstage` - Static Authenticator Stage + * `authentik_stages_authenticator_static.staticdevice` - Static device * `authentik_stages_authenticator_totp.authenticatortotpstage` - TOTP Authenticator Setup Stage + * `authentik_stages_authenticator_totp.totpdevice` - TOTP device * `authentik_stages_authenticator_validate.authenticatorvalidatestage` - Authenticator Validation Stage * `authentik_stages_authenticator_webauthn.authenticatewebauthnstage` - WebAuthn Authenticator Setup Stage * `authentik_stages_authenticator_webauthn.webauthndevice` - WebAuthn Device @@ -29666,6 +29671,7 @@ components: * `authentik.sources.oauth` - authentik Sources.OAuth * `authentik.sources.plex` - authentik Sources.Plex * `authentik.sources.saml` - authentik Sources.SAML + * `authentik.stages.authenticator` - authentik Stages.Authenticator * `authentik.stages.authenticator_duo` - authentik Stages.Authenticator.Duo * `authentik.stages.authenticator_sms` - authentik Stages.Authenticator.SMS * `authentik.stages.authenticator_static` - authentik Stages.Authenticator.Static @@ -29740,7 +29746,9 @@ components: * `authentik_stages_authenticator_sms.authenticatorsmsstage` - SMS Authenticator Setup Stage * `authentik_stages_authenticator_sms.smsdevice` - SMS Device * `authentik_stages_authenticator_static.authenticatorstaticstage` - Static Authenticator Stage + * `authentik_stages_authenticator_static.staticdevice` - Static device * `authentik_stages_authenticator_totp.authenticatortotpstage` - TOTP Authenticator Setup Stage + * `authentik_stages_authenticator_totp.totpdevice` - TOTP device * `authentik_stages_authenticator_validate.authenticatorvalidatestage` - Authenticator Validation Stage * `authentik_stages_authenticator_webauthn.authenticatewebauthnstage` - WebAuthn Authenticator Setup Stage * `authentik_stages_authenticator_webauthn.webauthndevice` - WebAuthn Device @@ -31898,7 +31906,9 @@ components: - authentik_stages_authenticator_sms.authenticatorsmsstage - authentik_stages_authenticator_sms.smsdevice - authentik_stages_authenticator_static.authenticatorstaticstage + - authentik_stages_authenticator_static.staticdevice - authentik_stages_authenticator_totp.authenticatortotpstage + - authentik_stages_authenticator_totp.totpdevice - authentik_stages_authenticator_validate.authenticatorvalidatestage - authentik_stages_authenticator_webauthn.authenticatewebauthnstage - authentik_stages_authenticator_webauthn.webauthndevice @@ -31970,7 +31980,9 @@ components: * `authentik_stages_authenticator_sms.authenticatorsmsstage` - SMS Authenticator Setup Stage * `authentik_stages_authenticator_sms.smsdevice` - SMS Device * `authentik_stages_authenticator_static.authenticatorstaticstage` - Static Authenticator Stage + * `authentik_stages_authenticator_static.staticdevice` - Static device * `authentik_stages_authenticator_totp.authenticatortotpstage` - TOTP Authenticator Setup Stage + * `authentik_stages_authenticator_totp.totpdevice` - TOTP device * `authentik_stages_authenticator_validate.authenticatorvalidatestage` - Authenticator Validation Stage * `authentik_stages_authenticator_webauthn.authenticatewebauthnstage` - WebAuthn Authenticator Setup Stage * `authentik_stages_authenticator_webauthn.webauthndevice` - WebAuthn Device @@ -34883,6 +34895,7 @@ components: * `authentik.sources.oauth` - authentik Sources.OAuth * `authentik.sources.plex` - authentik Sources.Plex * `authentik.sources.saml` - authentik Sources.SAML + * `authentik.stages.authenticator` - authentik Stages.Authenticator * `authentik.stages.authenticator_duo` - authentik Stages.Authenticator.Duo * `authentik.stages.authenticator_sms` - authentik Stages.Authenticator.SMS * `authentik.stages.authenticator_static` - authentik Stages.Authenticator.Static @@ -34957,7 +34970,9 @@ components: * `authentik_stages_authenticator_sms.authenticatorsmsstage` - SMS Authenticator Setup Stage * `authentik_stages_authenticator_sms.smsdevice` - SMS Device * `authentik_stages_authenticator_static.authenticatorstaticstage` - Static Authenticator Stage + * `authentik_stages_authenticator_static.staticdevice` - Static device * `authentik_stages_authenticator_totp.authenticatortotpstage` - TOTP Authenticator Setup Stage + * `authentik_stages_authenticator_totp.totpdevice` - TOTP device * `authentik_stages_authenticator_validate.authenticatorvalidatestage` - Authenticator Validation Stage * `authentik_stages_authenticator_webauthn.authenticatewebauthnstage` - WebAuthn Authenticator Setup Stage * `authentik_stages_authenticator_webauthn.webauthndevice` - WebAuthn Device diff --git a/tests/e2e/test_flows_authenticators.py b/tests/e2e/test_flows_authenticators.py index 100b489c9..80e55b6a6 100644 --- a/tests/e2e/test_flows_authenticators.py +++ b/tests/e2e/test_flows_authenticators.py @@ -3,9 +3,6 @@ from base64 import b32decode from time import sleep from urllib.parse import parse_qs, urlparse -from django_otp.oath import TOTP -from django_otp.plugins.otp_static.models import StaticDevice, StaticToken -from django_otp.plugins.otp_totp.models import TOTPDevice from selenium.webdriver.common.by import By from selenium.webdriver.common.keys import Keys from selenium.webdriver.support import expected_conditions as ec @@ -13,8 +10,13 @@ from selenium.webdriver.support.wait import WebDriverWait from authentik.blueprints.tests import apply_blueprint from authentik.flows.models import Flow -from authentik.stages.authenticator_static.models import AuthenticatorStaticStage -from authentik.stages.authenticator_totp.models import AuthenticatorTOTPStage +from authentik.stages.authenticator.oath import TOTP +from authentik.stages.authenticator_static.models import ( + AuthenticatorStaticStage, + StaticDevice, + StaticToken, +) +from authentik.stages.authenticator_totp.models import AuthenticatorTOTPStage, TOTPDevice from tests.e2e.utils import SeleniumTestCase, retry diff --git a/web/src/elements/user/UserDevicesList.ts b/web/src/elements/user/UserDevicesList.ts index 3e43d5547..1c7190dc4 100644 --- a/web/src/elements/user/UserDevicesList.ts +++ b/web/src/elements/user/UserDevicesList.ts @@ -46,11 +46,11 @@ export class UserDeviceList extends MFADevicesPage { return new AuthenticatorsApi(DEFAULT_CONFIG).authenticatorsAdminSmsDestroy({ id: device.pk, }); - case "otp_totp.TOTPDevice": + case "authentik_stages_authenticator_totp.TOTPDevice": return new AuthenticatorsApi(DEFAULT_CONFIG).authenticatorsAdminTotpDestroy({ id: device.pk, }); - case "otp_static.StaticDevice": + case "authentik_stages_authenticator_static.StaticDevice": return new AuthenticatorsApi(DEFAULT_CONFIG).authenticatorsAdminStaticDestroy({ id: device.pk, }); diff --git a/web/src/user/user-settings/mfa/MFADeviceForm.ts b/web/src/user/user-settings/mfa/MFADeviceForm.ts index a2ea4ce09..0901d0bc9 100644 --- a/web/src/user/user-settings/mfa/MFADeviceForm.ts +++ b/web/src/user/user-settings/mfa/MFADeviceForm.ts @@ -39,13 +39,13 @@ export class MFADeviceForm extends ModelForm { sMSDeviceRequest: device, }); break; - case "otp_totp.TOTPDevice": + case "authentik_stages_authenticator_totp.TOTPDevice": await new AuthenticatorsApi(DEFAULT_CONFIG).authenticatorsTotpUpdate({ id: this.instance?.pk, tOTPDeviceRequest: device, }); break; - case "otp_static.StaticDevice": + case "authentik_stages_authenticator_static.StaticDevice": await new AuthenticatorsApi(DEFAULT_CONFIG).authenticatorsStaticUpdate({ id: this.instance?.pk, staticDeviceRequest: device, diff --git a/web/src/user/user-settings/mfa/MFADevicesPage.ts b/web/src/user/user-settings/mfa/MFADevicesPage.ts index 510fae335..eb29a13de 100644 --- a/web/src/user/user-settings/mfa/MFADevicesPage.ts +++ b/web/src/user/user-settings/mfa/MFADevicesPage.ts @@ -24,9 +24,9 @@ export function stageToAuthenticatorName(stage: UserSetting): string { export function deviceTypeName(device: Device): string { switch (device.type) { - case "otp_static.StaticDevice": + case "authentik_stages_authenticator_static.StaticDevice": return msg("Static tokens"); - case "otp_totp.TOTPDevice": + case "authentik_stages_authenticator_totp.TOTPDevice": return msg("TOTP Device"); default: return device.verboseName; @@ -102,11 +102,11 @@ export class MFADevicesPage extends Table { return new AuthenticatorsApi(DEFAULT_CONFIG).authenticatorsSmsDestroy({ id: device.pk, }); - case "otp_totp.TOTPDevice": + case "authentik_stages_authenticator_totp.TOTPDevice": return new AuthenticatorsApi(DEFAULT_CONFIG).authenticatorsTotpDestroy({ id: device.pk, }); - case "otp_static.StaticDevice": + case "authentik_stages_authenticator_static.StaticDevice": return new AuthenticatorsApi(DEFAULT_CONFIG).authenticatorsStaticDestroy({ id: device.pk, });