stages/authenticator: vendor otp (#6741)
* initial import Signed-off-by: Jens Langhammer <jens@goauthentik.io> * update imports Signed-off-by: Jens Langhammer <jens@goauthentik.io> * remove email and hotp for now Signed-off-by: Jens Langhammer <jens@goauthentik.io> * remove things we don't need and clean up Signed-off-by: Jens Langhammer <jens@goauthentik.io> * initial merge static Signed-off-by: Jens Langhammer <jens@goauthentik.io> * initial merge totp Signed-off-by: Jens Langhammer <jens@goauthentik.io> * more fixes Signed-off-by: Jens Langhammer <jens@goauthentik.io> * fix migrations Signed-off-by: Jens Langhammer <jens@goauthentik.io> * update webui Signed-off-by: Jens Langhammer <jens@goauthentik.io> * add system migration Signed-off-by: Jens Langhammer <jens@goauthentik.io> * more cleanup, add doctests to test_runner Signed-off-by: Jens Langhammer <jens@goauthentik.io> * more cleanup Signed-off-by: Jens Langhammer <jens@goauthentik.io> * fixup more lint Signed-off-by: Jens Langhammer <jens@goauthentik.io> * cleanup last tests Signed-off-by: Jens Langhammer <jens@goauthentik.io> * update docstrings Signed-off-by: Jens Langhammer <jens@goauthentik.io> * fix tests Signed-off-by: Jens Langhammer <jens@goauthentik.io> * implement SerializerModel Signed-off-by: Jens Langhammer <jens@goauthentik.io> * fix web format Signed-off-by: Jens Langhammer <jens@goauthentik.io> --------- Signed-off-by: Jens Langhammer <jens@goauthentik.io>
This commit is contained in:
parent
3f12c7c013
commit
6612f729ec
|
@ -1,6 +1,4 @@
|
||||||
"""Authenticator Devices API Views"""
|
"""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.types import OpenApiTypes
|
||||||
from drf_spectacular.utils import OpenApiParameter, extend_schema
|
from drf_spectacular.utils import OpenApiParameter, extend_schema
|
||||||
from rest_framework.fields import BooleanField, CharField, IntegerField, SerializerMethodField
|
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 rest_framework.viewsets import ViewSet
|
||||||
|
|
||||||
from authentik.core.api.utils import MetaNameSerializer
|
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):
|
class DeviceSerializer(MetaNameSerializer):
|
||||||
|
|
|
@ -25,10 +25,10 @@ def create_test_admin_user(name: Optional[str] = None, **kwargs) -> User:
|
||||||
"""Generate a test-admin user"""
|
"""Generate a test-admin user"""
|
||||||
uid = generate_id(20) if not name else name
|
uid = generate_id(20) if not name else name
|
||||||
group = Group.objects.create(name=uid, is_superuser=True)
|
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(
|
user: User = User.objects.create(
|
||||||
username=uid,
|
|
||||||
name=uid,
|
name=uid,
|
||||||
email=f"{uid}@goauthentik.io",
|
|
||||||
**kwargs,
|
**kwargs,
|
||||||
)
|
)
|
||||||
user.set_password(uid)
|
user.set_password(uid)
|
||||||
|
|
|
@ -1,44 +1,30 @@
|
||||||
"""Enterprise license policies"""
|
"""Enterprise license policies"""
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from rest_framework.serializers import BaseSerializer
|
|
||||||
|
|
||||||
from authentik.core.models import User, UserTypes
|
from authentik.core.models import User, UserTypes
|
||||||
from authentik.enterprise.models import LicenseKey
|
from authentik.enterprise.models import LicenseKey
|
||||||
from authentik.policies.models import Policy
|
|
||||||
from authentik.policies.types import PolicyRequest, PolicyResult
|
from authentik.policies.types import PolicyRequest, PolicyResult
|
||||||
from authentik.policies.views import PolicyAccessView
|
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):
|
class EnterprisePolicyAccessView(PolicyAccessView):
|
||||||
"""PolicyAccessView which also checks enterprise licensing"""
|
"""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:
|
def user_has_access(self, user: Optional[User] = None) -> PolicyResult:
|
||||||
user = user or self.request.user
|
user = user or self.request.user
|
||||||
request = PolicyRequest(user)
|
request = PolicyRequest(user)
|
||||||
request.http_request = self.request
|
request.http_request = self.request
|
||||||
result = super().user_has_access(user)
|
result = super().user_has_access(user)
|
||||||
enterprise_result = EnterprisePolicy().passes(request)
|
enterprise_result = self.check_license()
|
||||||
if not enterprise_result.passing:
|
if not enterprise_result:
|
||||||
return enterprise_result
|
return enterprise_result
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
|
@ -9,7 +9,6 @@ from django.core.exceptions import SuspiciousOperation
|
||||||
from django.db.models import Model
|
from django.db.models import Model
|
||||||
from django.db.models.signals import m2m_changed, post_save, pre_delete
|
from django.db.models.signals import m2m_changed, post_save, pre_delete
|
||||||
from django.http import HttpRequest, HttpResponse
|
from django.http import HttpRequest, HttpResponse
|
||||||
from django_otp.plugins.otp_static.models import StaticToken
|
|
||||||
from guardian.models import UserObjectPermission
|
from guardian.models import UserObjectPermission
|
||||||
|
|
||||||
from authentik.core.models import (
|
from authentik.core.models import (
|
||||||
|
@ -30,6 +29,7 @@ from authentik.outposts.models import OutpostServiceConnection
|
||||||
from authentik.policies.models import Policy, PolicyBindingModel
|
from authentik.policies.models import Policy, PolicyBindingModel
|
||||||
from authentik.providers.oauth2.models import AccessToken, AuthorizationCode, RefreshToken
|
from authentik.providers.oauth2.models import AccessToken, AuthorizationCode, RefreshToken
|
||||||
from authentik.providers.scim.models import SCIMGroup, SCIMUser
|
from authentik.providers.scim.models import SCIMGroup, SCIMUser
|
||||||
|
from authentik.stages.authenticator_static.models import StaticToken
|
||||||
|
|
||||||
IGNORED_MODELS = (
|
IGNORED_MODELS = (
|
||||||
Event,
|
Event,
|
||||||
|
|
|
@ -7,7 +7,6 @@ from typing import Any, Iterable, Optional
|
||||||
|
|
||||||
from cachetools import TLRUCache, cached
|
from cachetools import TLRUCache, cached
|
||||||
from django.core.exceptions import FieldError
|
from django.core.exceptions import FieldError
|
||||||
from django_otp import devices_for_user
|
|
||||||
from guardian.shortcuts import get_anonymous_user
|
from guardian.shortcuts import get_anonymous_user
|
||||||
from rest_framework.serializers import ValidationError
|
from rest_framework.serializers import ValidationError
|
||||||
from sentry_sdk.hub import Hub
|
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.models import Policy, PolicyBinding
|
||||||
from authentik.policies.process import PolicyProcess
|
from authentik.policies.process import PolicyProcess
|
||||||
from authentik.policies.types import PolicyRequest, PolicyResult
|
from authentik.policies.types import PolicyRequest, PolicyResult
|
||||||
|
from authentik.stages.authenticator import devices_for_user
|
||||||
|
|
||||||
LOGGER = get_logger()
|
LOGGER = get_logger()
|
||||||
|
|
||||||
|
|
|
@ -81,6 +81,7 @@ INSTALLED_APPS = [
|
||||||
"authentik.sources.oauth",
|
"authentik.sources.oauth",
|
||||||
"authentik.sources.plex",
|
"authentik.sources.plex",
|
||||||
"authentik.sources.saml",
|
"authentik.sources.saml",
|
||||||
|
"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",
|
||||||
|
|
|
@ -20,7 +20,7 @@ class PytestTestRunner: # pragma: no cover
|
||||||
self.failfast = failfast
|
self.failfast = failfast
|
||||||
self.keepdb = keepdb
|
self.keepdb = keepdb
|
||||||
|
|
||||||
self.args = ["-vv", "--full-trace"]
|
self.args = []
|
||||||
if self.failfast:
|
if self.failfast:
|
||||||
self.args.append("--exitfirst")
|
self.args.append("--exitfirst")
|
||||||
if self.keepdb:
|
if self.keepdb:
|
||||||
|
|
|
@ -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
|
|
@ -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"
|
|
@ -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 "<app_label>.<model_name>" 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
|
|
@ -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
|
||||||
|
<http://tools.ietf.org/html/rfc4226#section-5>`_.
|
||||||
|
|
||||||
|
: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
|
||||||
|
<http://tools.ietf.org/html/rfc6238#section-4>`_.
|
||||||
|
|
||||||
|
: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
|
|
@ -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)
|
|
@ -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)
|
|
@ -5,7 +5,6 @@ from django.contrib.auth import get_user_model
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from django.views import View
|
from django.views import View
|
||||||
from django_otp.models import Device
|
|
||||||
from duo_client.admin import Admin
|
from duo_client.admin import Admin
|
||||||
from duo_client.auth import Auth
|
from duo_client.auth import Auth
|
||||||
from rest_framework.serializers import BaseSerializer, Serializer
|
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.flows.models import ConfigurableStage, FriendlyNamedStage, Stage
|
||||||
from authentik.lib.models import SerializerModel
|
from authentik.lib.models import SerializerModel
|
||||||
from authentik.lib.utils.http import authentik_user_agent
|
from authentik.lib.utils.http import authentik_user_agent
|
||||||
|
from authentik.stages.authenticator.models import Device
|
||||||
|
|
||||||
|
|
||||||
class AuthenticatorDuoStage(ConfigurableStage, FriendlyNamedStage, Stage):
|
class AuthenticatorDuoStage(ConfigurableStage, FriendlyNamedStage, Stage):
|
||||||
|
|
|
@ -6,7 +6,6 @@ from django.contrib.auth import get_user_model
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from django.views import View
|
from django.views import View
|
||||||
from django_otp.models import SideChannelDevice
|
|
||||||
from requests.exceptions import RequestException
|
from requests.exceptions import RequestException
|
||||||
from rest_framework.exceptions import ValidationError
|
from rest_framework.exceptions import ValidationError
|
||||||
from rest_framework.serializers import BaseSerializer
|
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.models import SerializerModel
|
||||||
from authentik.lib.utils.errors import exception_to_string
|
from authentik.lib.utils.errors import exception_to_string
|
||||||
from authentik.lib.utils.http import get_http_session
|
from authentik.lib.utils.http import get_http_session
|
||||||
|
from authentik.stages.authenticator.models import SideChannelDevice
|
||||||
|
|
||||||
LOGGER = get_logger()
|
LOGGER = get_logger()
|
||||||
|
|
||||||
|
|
|
@ -1,6 +1,5 @@
|
||||||
"""AuthenticatorStaticStage API Views"""
|
"""AuthenticatorStaticStage API Views"""
|
||||||
from django_filters.rest_framework import DjangoFilterBackend
|
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 import mixins
|
||||||
from rest_framework.filters import OrderingFilter, SearchFilter
|
from rest_framework.filters import OrderingFilter, SearchFilter
|
||||||
from rest_framework.permissions import IsAdminUser
|
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.api.authorization import OwnerFilter, OwnerPermissions
|
||||||
from authentik.core.api.used_by import UsedByMixin
|
from authentik.core.api.used_by import UsedByMixin
|
||||||
from authentik.flows.api.stages import StageSerializer
|
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):
|
class AuthenticatorStaticStageSerializer(StageSerializer):
|
||||||
|
|
|
@ -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,),
|
||||||
|
),
|
||||||
|
]
|
|
@ -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"},
|
||||||
|
),
|
||||||
|
]
|
|
@ -1,6 +1,9 @@
|
||||||
"""Static Authenticator models"""
|
"""Static Authenticator models"""
|
||||||
|
from base64 import b32encode
|
||||||
|
from os import urandom
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from django.views import View
|
from django.views import View
|
||||||
|
@ -8,6 +11,8 @@ from rest_framework.serializers import BaseSerializer
|
||||||
|
|
||||||
from authentik.core.types import UserSettingSerializer
|
from authentik.core.types import UserSettingSerializer
|
||||||
from authentik.flows.models import ConfigurableStage, FriendlyNamedStage, Stage
|
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):
|
class AuthenticatorStaticStage(ConfigurableStage, FriendlyNamedStage, Stage):
|
||||||
|
@ -46,3 +51,76 @@ class AuthenticatorStaticStage(ConfigurableStage, FriendlyNamedStage, Stage):
|
||||||
class Meta:
|
class Meta:
|
||||||
verbose_name = _("Static Authenticator Stage")
|
verbose_name = _("Static Authenticator Stage")
|
||||||
verbose_name_plural = _("Static Authenticator Stages")
|
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()
|
||||||
|
|
|
@ -1,5 +0,0 @@
|
||||||
"""Static Authenticator settings"""
|
|
||||||
|
|
||||||
INSTALLED_APPS = [
|
|
||||||
"django_otp.plugins.otp_static",
|
|
||||||
]
|
|
|
@ -1,9 +1,9 @@
|
||||||
"""totp authenticator signals"""
|
"""totp authenticator signals"""
|
||||||
from django.db.models.signals import pre_delete
|
from django.db.models.signals import pre_delete
|
||||||
from django.dispatch import receiver
|
from django.dispatch import receiver
|
||||||
from django_otp.plugins.otp_static.models import StaticDevice
|
|
||||||
|
|
||||||
from authentik.events.models import Event
|
from authentik.events.models import Event
|
||||||
|
from authentik.stages.authenticator_static.models import StaticDevice
|
||||||
|
|
||||||
|
|
||||||
@receiver(pre_delete, sender=StaticDevice)
|
@receiver(pre_delete, sender=StaticDevice)
|
||||||
|
|
|
@ -1,12 +1,15 @@
|
||||||
"""Static OTP Setup stage"""
|
"""Static OTP Setup stage"""
|
||||||
from django.http import HttpRequest, HttpResponse
|
from django.http import HttpRequest, HttpResponse
|
||||||
from django_otp.plugins.otp_static.models import StaticDevice, StaticToken
|
|
||||||
from rest_framework.fields import CharField, ListField
|
from rest_framework.fields import CharField, ListField
|
||||||
|
|
||||||
from authentik.flows.challenge import ChallengeResponse, ChallengeTypes, WithUserInfoChallenge
|
from authentik.flows.challenge import ChallengeResponse, ChallengeTypes, WithUserInfoChallenge
|
||||||
from authentik.flows.stage import ChallengeStageView
|
from authentik.flows.stage import ChallengeStageView
|
||||||
from authentik.lib.generators import generate_id
|
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_DEVICE = "static_device"
|
||||||
SESSION_STATIC_TOKENS = "static_device_tokens"
|
SESSION_STATIC_TOKENS = "static_device_tokens"
|
||||||
|
|
|
@ -1,9 +1,13 @@
|
||||||
"""Test Static API"""
|
"""Test Static API"""
|
||||||
|
from django.test.utils import override_settings
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django_otp.plugins.otp_static.models import StaticDevice
|
|
||||||
from rest_framework.test import APITestCase
|
from rest_framework.test import APITestCase
|
||||||
|
|
||||||
from authentik.core.models import User
|
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):
|
class AuthenticatorStaticStageTests(APITestCase):
|
||||||
|
@ -18,3 +22,42 @@ class AuthenticatorStaticStageTests(APITestCase):
|
||||||
reverse("authentik_api:staticdevice-detail", kwargs={"pk": dev.pk})
|
reverse("authentik_api:staticdevice-detail", kwargs={"pk": dev.pk})
|
||||||
)
|
)
|
||||||
self.assertEqual(response.status_code, 204)
|
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"
|
||||||
|
|
|
@ -1,6 +1,5 @@
|
||||||
"""AuthenticatorTOTPStage API Views"""
|
"""AuthenticatorTOTPStage API Views"""
|
||||||
from django_filters.rest_framework.backends import DjangoFilterBackend
|
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 import mixins
|
||||||
from rest_framework.filters import OrderingFilter, SearchFilter
|
from rest_framework.filters import OrderingFilter, SearchFilter
|
||||||
from rest_framework.permissions import IsAdminUser
|
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.api.authorization import OwnerFilter, OwnerPermissions
|
||||||
from authentik.core.api.used_by import UsedByMixin
|
from authentik.core.api.used_by import UsedByMixin
|
||||||
from authentik.flows.api.stages import StageSerializer
|
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):
|
class AuthenticatorTOTPStageSerializer(StageSerializer):
|
||||||
|
|
|
@ -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,),
|
||||||
|
),
|
||||||
|
]
|
|
@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
|
@ -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"},
|
||||||
|
),
|
||||||
|
]
|
|
@ -1,6 +1,11 @@
|
||||||
"""OTP Time-based models"""
|
"""OTP Time-based models"""
|
||||||
|
import time
|
||||||
|
from base64 import b32encode
|
||||||
|
from binascii import unhexlify
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
from urllib.parse import quote, urlencode
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from django.views import View
|
from django.views import View
|
||||||
|
@ -8,10 +13,14 @@ from rest_framework.serializers import BaseSerializer
|
||||||
|
|
||||||
from authentik.core.types import UserSettingSerializer
|
from authentik.core.types import UserSettingSerializer
|
||||||
from authentik.flows.models import ConfigurableStage, FriendlyNamedStage, Stage
|
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):
|
class TOTPDigits(models.IntegerChoices):
|
||||||
"""OTP Time Digits"""
|
"""OTP Time Digits"""
|
||||||
|
|
||||||
SIX = 6, _("6 digits, widely compatible")
|
SIX = 6, _("6 digits, widely compatible")
|
||||||
EIGHT = 8, _("8 digits, not compatible with apps like Google Authenticator")
|
EIGHT = 8, _("8 digits, not compatible with apps like Google Authenticator")
|
||||||
|
@ -52,3 +61,185 @@ class AuthenticatorTOTPStage(ConfigurableStage, FriendlyNamedStage, Stage):
|
||||||
class Meta:
|
class Meta:
|
||||||
verbose_name = _("TOTP Authenticator Setup Stage")
|
verbose_name = _("TOTP Authenticator Setup Stage")
|
||||||
verbose_name_plural = _("TOTP Authenticator Setup Stages")
|
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")
|
||||||
|
|
|
@ -1,6 +1,3 @@
|
||||||
"""OTP Time"""
|
"""OTP Time"""
|
||||||
|
|
||||||
INSTALLED_APPS = [
|
|
||||||
"django_otp.plugins.otp_totp",
|
|
||||||
]
|
|
||||||
OTP_TOTP_ISSUER = "__to_replace__"
|
OTP_TOTP_ISSUER = "__to_replace__"
|
||||||
|
|
|
@ -4,7 +4,6 @@ from urllib.parse import quote
|
||||||
from django.http import HttpRequest, HttpResponse
|
from django.http import HttpRequest, HttpResponse
|
||||||
from django.http.request import QueryDict
|
from django.http.request import QueryDict
|
||||||
from django.utils.translation import gettext_lazy as _
|
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.fields import CharField, IntegerField
|
||||||
from rest_framework.serializers import ValidationError
|
from rest_framework.serializers import ValidationError
|
||||||
|
|
||||||
|
@ -15,7 +14,7 @@ from authentik.flows.challenge import (
|
||||||
WithUserInfoChallenge,
|
WithUserInfoChallenge,
|
||||||
)
|
)
|
||||||
from authentik.flows.stage import ChallengeStageView
|
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
|
from authentik.stages.authenticator_totp.settings import OTP_TOTP_ISSUER
|
||||||
|
|
||||||
SESSION_TOTP_DEVICE = "totp_device"
|
SESSION_TOTP_DEVICE = "totp_device"
|
||||||
|
|
|
@ -1,9 +1,15 @@
|
||||||
"""Test TOTP API"""
|
"""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.urls import reverse
|
||||||
from django_otp.plugins.otp_totp.models import TOTPDevice
|
|
||||||
from rest_framework.test import APITestCase
|
from rest_framework.test import APITestCase
|
||||||
|
|
||||||
from authentik.core.models import User
|
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):
|
class AuthenticatorTOTPStage(APITestCase):
|
||||||
|
@ -18,3 +24,180 @@ class AuthenticatorTOTPStage(APITestCase):
|
||||||
reverse("authentik_api:totpdevice-detail", kwargs={"pk": dev.pk})
|
reverse("authentik_api:totpdevice-detail", kwargs={"pk": dev.pk})
|
||||||
)
|
)
|
||||||
self.assertEqual(response.status_code, 204)
|
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
|
||||||
|
|
|
@ -7,8 +7,6 @@ from django.http.response import Http404
|
||||||
from django.shortcuts import get_object_or_404
|
from django.shortcuts import get_object_or_404
|
||||||
from django.utils.translation import gettext as __
|
from django.utils.translation import gettext as __
|
||||||
from django.utils.translation import gettext_lazy 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.fields import CharField, JSONField
|
||||||
from rest_framework.serializers import ValidationError
|
from rest_framework.serializers import ValidationError
|
||||||
from structlog.stdlib import get_logger
|
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.stage import StageView
|
||||||
from authentik.flows.views.executor import SESSION_KEY_APPLICATION_PRE
|
from authentik.flows.views.executor import SESSION_KEY_APPLICATION_PRE
|
||||||
from authentik.lib.utils.http import get_client_ip
|
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_duo.models import AuthenticatorDuoStage, DuoDevice
|
||||||
from authentik.stages.authenticator_sms.models import SMSDevice
|
from authentik.stages.authenticator_sms.models import SMSDevice
|
||||||
from authentik.stages.authenticator_validate.models import AuthenticatorValidateStage, DeviceClasses
|
from authentik.stages.authenticator_validate.models import AuthenticatorValidateStage, DeviceClasses
|
||||||
|
|
|
@ -1,4 +0,0 @@
|
||||||
"""OTP Validate stage settings"""
|
|
||||||
INSTALLED_APPS = [
|
|
||||||
"django_otp",
|
|
||||||
]
|
|
|
@ -5,8 +5,6 @@ from typing import Optional
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.http import HttpRequest, HttpResponse
|
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 jwt import PyJWTError, decode, encode
|
||||||
from rest_framework.fields import CharField, IntegerField, JSONField, ListField, UUIDField
|
from rest_framework.fields import CharField, IntegerField, JSONField, ListField, UUIDField
|
||||||
from rest_framework.serializers import ValidationError
|
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.flows.stage import ChallengeStageView
|
||||||
from authentik.lib.utils.time import timedelta_from_string
|
from authentik.lib.utils.time import timedelta_from_string
|
||||||
from authentik.root.install_id import get_install_id
|
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_sms.models import SMSDevice
|
||||||
from authentik.stages.authenticator_validate.challenge import (
|
from authentik.stages.authenticator_validate.challenge import (
|
||||||
DeviceChallenge,
|
DeviceChallenge,
|
||||||
|
|
|
@ -5,8 +5,6 @@ from time import sleep
|
||||||
|
|
||||||
from django.test.client import RequestFactory
|
from django.test.client import RequestFactory
|
||||||
from django.urls.base import reverse
|
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 jwt import encode
|
||||||
from rest_framework.exceptions import ValidationError
|
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.flows.views.executor import FlowExecutorView
|
||||||
from authentik.lib.generators import generate_id
|
from authentik.lib.generators import generate_id
|
||||||
from authentik.root.install_id import get_install_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 (
|
from authentik.stages.authenticator_validate.challenge import (
|
||||||
get_challenge_for_device,
|
get_challenge_for_device,
|
||||||
validate_challenge_code,
|
validate_challenge_code,
|
||||||
|
|
|
@ -6,7 +6,6 @@ from django.db import models
|
||||||
from django.utils.timezone import now
|
from django.utils.timezone import now
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from django.views import View
|
from django.views import View
|
||||||
from django_otp.models import Device
|
|
||||||
from rest_framework.serializers import BaseSerializer, Serializer
|
from rest_framework.serializers import BaseSerializer, Serializer
|
||||||
from webauthn.helpers.base64url_to_bytes import base64url_to_bytes
|
from webauthn.helpers.base64url_to_bytes import base64url_to_bytes
|
||||||
from webauthn.helpers.structs import PublicKeyCredentialDescriptor
|
from webauthn.helpers.structs import PublicKeyCredentialDescriptor
|
||||||
|
@ -14,6 +13,7 @@ from webauthn.helpers.structs import PublicKeyCredentialDescriptor
|
||||||
from authentik.core.types import UserSettingSerializer
|
from authentik.core.types import UserSettingSerializer
|
||||||
from authentik.flows.models import ConfigurableStage, FriendlyNamedStage, Stage
|
from authentik.flows.models import ConfigurableStage, FriendlyNamedStage, Stage
|
||||||
from authentik.lib.models import SerializerModel
|
from authentik.lib.models import SerializerModel
|
||||||
|
from authentik.stages.authenticator.models import Device
|
||||||
|
|
||||||
|
|
||||||
class UserVerification(models.TextChoices):
|
class UserVerification(models.TextChoices):
|
||||||
|
|
|
@ -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",
|
"type": "object",
|
||||||
"required": [
|
"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",
|
"type": "object",
|
||||||
"required": [
|
"required": [
|
||||||
|
@ -3232,6 +3304,7 @@
|
||||||
"authentik.sources.oauth",
|
"authentik.sources.oauth",
|
||||||
"authentik.sources.plex",
|
"authentik.sources.plex",
|
||||||
"authentik.sources.saml",
|
"authentik.sources.saml",
|
||||||
|
"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",
|
||||||
|
@ -3310,7 +3383,9 @@
|
||||||
"authentik_stages_authenticator_sms.authenticatorsmsstage",
|
"authentik_stages_authenticator_sms.authenticatorsmsstage",
|
||||||
"authentik_stages_authenticator_sms.smsdevice",
|
"authentik_stages_authenticator_sms.smsdevice",
|
||||||
"authentik_stages_authenticator_static.authenticatorstaticstage",
|
"authentik_stages_authenticator_static.authenticatorstaticstage",
|
||||||
|
"authentik_stages_authenticator_static.staticdevice",
|
||||||
"authentik_stages_authenticator_totp.authenticatortotpstage",
|
"authentik_stages_authenticator_totp.authenticatortotpstage",
|
||||||
|
"authentik_stages_authenticator_totp.totpdevice",
|
||||||
"authentik_stages_authenticator_validate.authenticatorvalidatestage",
|
"authentik_stages_authenticator_validate.authenticatorvalidatestage",
|
||||||
"authentik_stages_authenticator_webauthn.authenticatewebauthnstage",
|
"authentik_stages_authenticator_webauthn.authenticatewebauthnstage",
|
||||||
"authentik_stages_authenticator_webauthn.webauthndevice",
|
"authentik_stages_authenticator_webauthn.webauthndevice",
|
||||||
|
@ -5872,6 +5947,19 @@
|
||||||
},
|
},
|
||||||
"required": []
|
"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": {
|
"model_authentik_stages_authenticator_totp.authenticatortotpstage": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
@ -5986,6 +6074,19 @@
|
||||||
},
|
},
|
||||||
"required": []
|
"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": {
|
"model_authentik_stages_authenticator_validate.authenticatorvalidatestage": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
|
|
@ -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"
|
||||||
|
)
|
|
@ -1185,23 +1185,6 @@ files = [
|
||||||
[package.dependencies]
|
[package.dependencies]
|
||||||
Django = ">=3.2"
|
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]]
|
[[package]]
|
||||||
name = "django-prometheus"
|
name = "django-prometheus"
|
||||||
version = "2.3.1"
|
version = "2.3.1"
|
||||||
|
@ -1458,6 +1441,20 @@ prometheus-client = ">=0.8.0"
|
||||||
pytz = "*"
|
pytz = "*"
|
||||||
tornado = ">=5.0.0,<7.0.0"
|
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]]
|
[[package]]
|
||||||
name = "frozenlist"
|
name = "frozenlist"
|
||||||
version = "1.4.0"
|
version = "1.4.0"
|
||||||
|
@ -4456,4 +4453,4 @@ files = [
|
||||||
[metadata]
|
[metadata]
|
||||||
lock-version = "2.0"
|
lock-version = "2.0"
|
||||||
python-versions = "^3.11"
|
python-versions = "^3.11"
|
||||||
content-hash = "8604e4dac9b0dcc55daccab83d4c182981d21201ef9901cf9c1acdc24288f979"
|
content-hash = "e6b1df989cb5c50609540c1229d05d8458ef1cc343fb5868402db8b7679ad73c"
|
||||||
|
|
|
@ -95,7 +95,7 @@ extension-pkg-whitelist = ["lxml", "xmlsec"]
|
||||||
# Allow constants to be shorter than normal (and lowercase, for settings.py)
|
# Allow constants to be shorter than normal (and lowercase, for settings.py)
|
||||||
const-rgx = "[a-zA-Z0-9_]{1,40}$"
|
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.*"]
|
generated-members = ["xmlsec.constants.*", "xmlsec.tree.*", "xmlsec.template.*"]
|
||||||
ignore = "migrations"
|
ignore = "migrations"
|
||||||
max-attributes = 12
|
max-attributes = 12
|
||||||
|
@ -105,7 +105,7 @@ max-branches = 20
|
||||||
DJANGO_SETTINGS_MODULE = "authentik.root.settings"
|
DJANGO_SETTINGS_MODULE = "authentik.root.settings"
|
||||||
python_files = ["tests.py", "test_*.py", "*_tests.py"]
|
python_files = ["tests.py", "test_*.py", "*_tests.py"]
|
||||||
junit_family = "xunit2"
|
junit_family = "xunit2"
|
||||||
addopts = "-p no:celery --junitxml=unittest.xml"
|
addopts = "-p no:celery --junitxml=unittest.xml -vv --full-trace --doctest-modules"
|
||||||
filterwarnings = [
|
filterwarnings = [
|
||||||
"ignore:defusedxml.lxml is no longer supported and will be removed in a future release.:DeprecationWarning",
|
"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",
|
"ignore:SelectableGroups dict interface is deprecated. Use select.:DeprecationWarning",
|
||||||
|
@ -131,7 +131,6 @@ django = "*"
|
||||||
django-filter = "*"
|
django-filter = "*"
|
||||||
django-guardian = "*"
|
django-guardian = "*"
|
||||||
django-model-utils = "*"
|
django-model-utils = "*"
|
||||||
django-otp = "*"
|
|
||||||
django-prometheus = "*"
|
django-prometheus = "*"
|
||||||
django-redis = "*"
|
django-redis = "*"
|
||||||
djangorestframework = "*"
|
djangorestframework = "*"
|
||||||
|
@ -183,6 +182,7 @@ coverage = { extras = ["toml"], version = "*" }
|
||||||
debugpy = "*"
|
debugpy = "*"
|
||||||
django-silk = "*"
|
django-silk = "*"
|
||||||
drf-jsonschema-serializer = "*"
|
drf-jsonschema-serializer = "*"
|
||||||
|
freezegun = "*"
|
||||||
importlib-metadata = "*"
|
importlib-metadata = "*"
|
||||||
pdoc = "*"
|
pdoc = "*"
|
||||||
pylint = "*"
|
pylint = "*"
|
||||||
|
|
33
schema.yml
33
schema.yml
|
@ -885,7 +885,7 @@ paths:
|
||||||
name: id
|
name: id
|
||||||
schema:
|
schema:
|
||||||
type: integer
|
type: integer
|
||||||
description: A unique integer value identifying this static device.
|
description: A unique integer value identifying this Static device.
|
||||||
required: true
|
required: true
|
||||||
tags:
|
tags:
|
||||||
- authenticators
|
- authenticators
|
||||||
|
@ -918,7 +918,7 @@ paths:
|
||||||
name: id
|
name: id
|
||||||
schema:
|
schema:
|
||||||
type: integer
|
type: integer
|
||||||
description: A unique integer value identifying this static device.
|
description: A unique integer value identifying this Static device.
|
||||||
required: true
|
required: true
|
||||||
tags:
|
tags:
|
||||||
- authenticators
|
- authenticators
|
||||||
|
@ -957,7 +957,7 @@ paths:
|
||||||
name: id
|
name: id
|
||||||
schema:
|
schema:
|
||||||
type: integer
|
type: integer
|
||||||
description: A unique integer value identifying this static device.
|
description: A unique integer value identifying this Static device.
|
||||||
required: true
|
required: true
|
||||||
tags:
|
tags:
|
||||||
- authenticators
|
- authenticators
|
||||||
|
@ -995,7 +995,7 @@ paths:
|
||||||
name: id
|
name: id
|
||||||
schema:
|
schema:
|
||||||
type: integer
|
type: integer
|
||||||
description: A unique integer value identifying this static device.
|
description: A unique integer value identifying this Static device.
|
||||||
required: true
|
required: true
|
||||||
tags:
|
tags:
|
||||||
- authenticators
|
- authenticators
|
||||||
|
@ -2030,7 +2030,7 @@ paths:
|
||||||
name: id
|
name: id
|
||||||
schema:
|
schema:
|
||||||
type: integer
|
type: integer
|
||||||
description: A unique integer value identifying this static device.
|
description: A unique integer value identifying this Static device.
|
||||||
required: true
|
required: true
|
||||||
tags:
|
tags:
|
||||||
- authenticators
|
- authenticators
|
||||||
|
@ -2063,7 +2063,7 @@ paths:
|
||||||
name: id
|
name: id
|
||||||
schema:
|
schema:
|
||||||
type: integer
|
type: integer
|
||||||
description: A unique integer value identifying this static device.
|
description: A unique integer value identifying this Static device.
|
||||||
required: true
|
required: true
|
||||||
tags:
|
tags:
|
||||||
- authenticators
|
- authenticators
|
||||||
|
@ -2102,7 +2102,7 @@ paths:
|
||||||
name: id
|
name: id
|
||||||
schema:
|
schema:
|
||||||
type: integer
|
type: integer
|
||||||
description: A unique integer value identifying this static device.
|
description: A unique integer value identifying this Static device.
|
||||||
required: true
|
required: true
|
||||||
tags:
|
tags:
|
||||||
- authenticators
|
- authenticators
|
||||||
|
@ -2140,7 +2140,7 @@ paths:
|
||||||
name: id
|
name: id
|
||||||
schema:
|
schema:
|
||||||
type: integer
|
type: integer
|
||||||
description: A unique integer value identifying this static device.
|
description: A unique integer value identifying this Static device.
|
||||||
required: true
|
required: true
|
||||||
tags:
|
tags:
|
||||||
- authenticators
|
- authenticators
|
||||||
|
@ -2170,7 +2170,7 @@ paths:
|
||||||
name: id
|
name: id
|
||||||
schema:
|
schema:
|
||||||
type: integer
|
type: integer
|
||||||
description: A unique integer value identifying this static device.
|
description: A unique integer value identifying this Static device.
|
||||||
required: true
|
required: true
|
||||||
tags:
|
tags:
|
||||||
- authenticators
|
- authenticators
|
||||||
|
@ -26693,6 +26693,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_duo
|
- authentik.stages.authenticator_duo
|
||||||
- authentik.stages.authenticator_sms
|
- authentik.stages.authenticator_sms
|
||||||
- authentik.stages.authenticator_static
|
- authentik.stages.authenticator_static
|
||||||
|
@ -26742,6 +26743,7 @@ components:
|
||||||
* `authentik.sources.oauth` - authentik Sources.OAuth
|
* `authentik.sources.oauth` - authentik Sources.OAuth
|
||||||
* `authentik.sources.plex` - authentik Sources.Plex
|
* `authentik.sources.plex` - authentik Sources.Plex
|
||||||
* `authentik.sources.saml` - authentik Sources.SAML
|
* `authentik.sources.saml` - authentik Sources.SAML
|
||||||
|
* `authentik.stages.authenticator` - authentik Stages.Authenticator
|
||||||
* `authentik.stages.authenticator_duo` - authentik Stages.Authenticator.Duo
|
* `authentik.stages.authenticator_duo` - authentik Stages.Authenticator.Duo
|
||||||
* `authentik.stages.authenticator_sms` - authentik Stages.Authenticator.SMS
|
* `authentik.stages.authenticator_sms` - authentik Stages.Authenticator.SMS
|
||||||
* `authentik.stages.authenticator_static` - authentik Stages.Authenticator.Static
|
* `authentik.stages.authenticator_static` - authentik Stages.Authenticator.Static
|
||||||
|
@ -29476,6 +29478,7 @@ components:
|
||||||
* `authentik.sources.oauth` - authentik Sources.OAuth
|
* `authentik.sources.oauth` - authentik Sources.OAuth
|
||||||
* `authentik.sources.plex` - authentik Sources.Plex
|
* `authentik.sources.plex` - authentik Sources.Plex
|
||||||
* `authentik.sources.saml` - authentik Sources.SAML
|
* `authentik.sources.saml` - authentik Sources.SAML
|
||||||
|
* `authentik.stages.authenticator` - authentik Stages.Authenticator
|
||||||
* `authentik.stages.authenticator_duo` - authentik Stages.Authenticator.Duo
|
* `authentik.stages.authenticator_duo` - authentik Stages.Authenticator.Duo
|
||||||
* `authentik.stages.authenticator_sms` - authentik Stages.Authenticator.SMS
|
* `authentik.stages.authenticator_sms` - authentik Stages.Authenticator.SMS
|
||||||
* `authentik.stages.authenticator_static` - authentik Stages.Authenticator.Static
|
* `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.authenticatorsmsstage` - SMS Authenticator Setup Stage
|
||||||
* `authentik_stages_authenticator_sms.smsdevice` - SMS Device
|
* `authentik_stages_authenticator_sms.smsdevice` - SMS Device
|
||||||
* `authentik_stages_authenticator_static.authenticatorstaticstage` - Static Authenticator Stage
|
* `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.authenticatortotpstage` - TOTP Authenticator Setup Stage
|
||||||
|
* `authentik_stages_authenticator_totp.totpdevice` - TOTP device
|
||||||
* `authentik_stages_authenticator_validate.authenticatorvalidatestage` - Authenticator Validation Stage
|
* `authentik_stages_authenticator_validate.authenticatorvalidatestage` - Authenticator Validation Stage
|
||||||
* `authentik_stages_authenticator_webauthn.authenticatewebauthnstage` - WebAuthn Authenticator Setup Stage
|
* `authentik_stages_authenticator_webauthn.authenticatewebauthnstage` - WebAuthn Authenticator Setup Stage
|
||||||
* `authentik_stages_authenticator_webauthn.webauthndevice` - WebAuthn Device
|
* `authentik_stages_authenticator_webauthn.webauthndevice` - WebAuthn Device
|
||||||
|
@ -29666,6 +29671,7 @@ components:
|
||||||
* `authentik.sources.oauth` - authentik Sources.OAuth
|
* `authentik.sources.oauth` - authentik Sources.OAuth
|
||||||
* `authentik.sources.plex` - authentik Sources.Plex
|
* `authentik.sources.plex` - authentik Sources.Plex
|
||||||
* `authentik.sources.saml` - authentik Sources.SAML
|
* `authentik.sources.saml` - authentik Sources.SAML
|
||||||
|
* `authentik.stages.authenticator` - authentik Stages.Authenticator
|
||||||
* `authentik.stages.authenticator_duo` - authentik Stages.Authenticator.Duo
|
* `authentik.stages.authenticator_duo` - authentik Stages.Authenticator.Duo
|
||||||
* `authentik.stages.authenticator_sms` - authentik Stages.Authenticator.SMS
|
* `authentik.stages.authenticator_sms` - authentik Stages.Authenticator.SMS
|
||||||
* `authentik.stages.authenticator_static` - authentik Stages.Authenticator.Static
|
* `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.authenticatorsmsstage` - SMS Authenticator Setup Stage
|
||||||
* `authentik_stages_authenticator_sms.smsdevice` - SMS Device
|
* `authentik_stages_authenticator_sms.smsdevice` - SMS Device
|
||||||
* `authentik_stages_authenticator_static.authenticatorstaticstage` - Static Authenticator Stage
|
* `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.authenticatortotpstage` - TOTP Authenticator Setup Stage
|
||||||
|
* `authentik_stages_authenticator_totp.totpdevice` - TOTP device
|
||||||
* `authentik_stages_authenticator_validate.authenticatorvalidatestage` - Authenticator Validation Stage
|
* `authentik_stages_authenticator_validate.authenticatorvalidatestage` - Authenticator Validation Stage
|
||||||
* `authentik_stages_authenticator_webauthn.authenticatewebauthnstage` - WebAuthn Authenticator Setup Stage
|
* `authentik_stages_authenticator_webauthn.authenticatewebauthnstage` - WebAuthn Authenticator Setup Stage
|
||||||
* `authentik_stages_authenticator_webauthn.webauthndevice` - WebAuthn Device
|
* `authentik_stages_authenticator_webauthn.webauthndevice` - WebAuthn Device
|
||||||
|
@ -31898,7 +31906,9 @@ components:
|
||||||
- authentik_stages_authenticator_sms.authenticatorsmsstage
|
- authentik_stages_authenticator_sms.authenticatorsmsstage
|
||||||
- authentik_stages_authenticator_sms.smsdevice
|
- authentik_stages_authenticator_sms.smsdevice
|
||||||
- authentik_stages_authenticator_static.authenticatorstaticstage
|
- authentik_stages_authenticator_static.authenticatorstaticstage
|
||||||
|
- authentik_stages_authenticator_static.staticdevice
|
||||||
- authentik_stages_authenticator_totp.authenticatortotpstage
|
- authentik_stages_authenticator_totp.authenticatortotpstage
|
||||||
|
- authentik_stages_authenticator_totp.totpdevice
|
||||||
- authentik_stages_authenticator_validate.authenticatorvalidatestage
|
- authentik_stages_authenticator_validate.authenticatorvalidatestage
|
||||||
- authentik_stages_authenticator_webauthn.authenticatewebauthnstage
|
- authentik_stages_authenticator_webauthn.authenticatewebauthnstage
|
||||||
- authentik_stages_authenticator_webauthn.webauthndevice
|
- authentik_stages_authenticator_webauthn.webauthndevice
|
||||||
|
@ -31970,7 +31980,9 @@ components:
|
||||||
* `authentik_stages_authenticator_sms.authenticatorsmsstage` - SMS Authenticator Setup Stage
|
* `authentik_stages_authenticator_sms.authenticatorsmsstage` - SMS Authenticator Setup Stage
|
||||||
* `authentik_stages_authenticator_sms.smsdevice` - SMS Device
|
* `authentik_stages_authenticator_sms.smsdevice` - SMS Device
|
||||||
* `authentik_stages_authenticator_static.authenticatorstaticstage` - Static Authenticator Stage
|
* `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.authenticatortotpstage` - TOTP Authenticator Setup Stage
|
||||||
|
* `authentik_stages_authenticator_totp.totpdevice` - TOTP device
|
||||||
* `authentik_stages_authenticator_validate.authenticatorvalidatestage` - Authenticator Validation Stage
|
* `authentik_stages_authenticator_validate.authenticatorvalidatestage` - Authenticator Validation Stage
|
||||||
* `authentik_stages_authenticator_webauthn.authenticatewebauthnstage` - WebAuthn Authenticator Setup Stage
|
* `authentik_stages_authenticator_webauthn.authenticatewebauthnstage` - WebAuthn Authenticator Setup Stage
|
||||||
* `authentik_stages_authenticator_webauthn.webauthndevice` - WebAuthn Device
|
* `authentik_stages_authenticator_webauthn.webauthndevice` - WebAuthn Device
|
||||||
|
@ -34883,6 +34895,7 @@ components:
|
||||||
* `authentik.sources.oauth` - authentik Sources.OAuth
|
* `authentik.sources.oauth` - authentik Sources.OAuth
|
||||||
* `authentik.sources.plex` - authentik Sources.Plex
|
* `authentik.sources.plex` - authentik Sources.Plex
|
||||||
* `authentik.sources.saml` - authentik Sources.SAML
|
* `authentik.sources.saml` - authentik Sources.SAML
|
||||||
|
* `authentik.stages.authenticator` - authentik Stages.Authenticator
|
||||||
* `authentik.stages.authenticator_duo` - authentik Stages.Authenticator.Duo
|
* `authentik.stages.authenticator_duo` - authentik Stages.Authenticator.Duo
|
||||||
* `authentik.stages.authenticator_sms` - authentik Stages.Authenticator.SMS
|
* `authentik.stages.authenticator_sms` - authentik Stages.Authenticator.SMS
|
||||||
* `authentik.stages.authenticator_static` - authentik Stages.Authenticator.Static
|
* `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.authenticatorsmsstage` - SMS Authenticator Setup Stage
|
||||||
* `authentik_stages_authenticator_sms.smsdevice` - SMS Device
|
* `authentik_stages_authenticator_sms.smsdevice` - SMS Device
|
||||||
* `authentik_stages_authenticator_static.authenticatorstaticstage` - Static Authenticator Stage
|
* `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.authenticatortotpstage` - TOTP Authenticator Setup Stage
|
||||||
|
* `authentik_stages_authenticator_totp.totpdevice` - TOTP device
|
||||||
* `authentik_stages_authenticator_validate.authenticatorvalidatestage` - Authenticator Validation Stage
|
* `authentik_stages_authenticator_validate.authenticatorvalidatestage` - Authenticator Validation Stage
|
||||||
* `authentik_stages_authenticator_webauthn.authenticatewebauthnstage` - WebAuthn Authenticator Setup Stage
|
* `authentik_stages_authenticator_webauthn.authenticatewebauthnstage` - WebAuthn Authenticator Setup Stage
|
||||||
* `authentik_stages_authenticator_webauthn.webauthndevice` - WebAuthn Device
|
* `authentik_stages_authenticator_webauthn.webauthndevice` - WebAuthn Device
|
||||||
|
|
|
@ -3,9 +3,6 @@ from base64 import b32decode
|
||||||
from time import sleep
|
from time import sleep
|
||||||
from urllib.parse import parse_qs, urlparse
|
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.by import By
|
||||||
from selenium.webdriver.common.keys import Keys
|
from selenium.webdriver.common.keys import Keys
|
||||||
from selenium.webdriver.support import expected_conditions as ec
|
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.blueprints.tests import apply_blueprint
|
||||||
from authentik.flows.models import Flow
|
from authentik.flows.models import Flow
|
||||||
from authentik.stages.authenticator_static.models import AuthenticatorStaticStage
|
from authentik.stages.authenticator.oath import TOTP
|
||||||
from authentik.stages.authenticator_totp.models import AuthenticatorTOTPStage
|
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
|
from tests.e2e.utils import SeleniumTestCase, retry
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -46,11 +46,11 @@ export class UserDeviceList extends MFADevicesPage {
|
||||||
return new AuthenticatorsApi(DEFAULT_CONFIG).authenticatorsAdminSmsDestroy({
|
return new AuthenticatorsApi(DEFAULT_CONFIG).authenticatorsAdminSmsDestroy({
|
||||||
id: device.pk,
|
id: device.pk,
|
||||||
});
|
});
|
||||||
case "otp_totp.TOTPDevice":
|
case "authentik_stages_authenticator_totp.TOTPDevice":
|
||||||
return new AuthenticatorsApi(DEFAULT_CONFIG).authenticatorsAdminTotpDestroy({
|
return new AuthenticatorsApi(DEFAULT_CONFIG).authenticatorsAdminTotpDestroy({
|
||||||
id: device.pk,
|
id: device.pk,
|
||||||
});
|
});
|
||||||
case "otp_static.StaticDevice":
|
case "authentik_stages_authenticator_static.StaticDevice":
|
||||||
return new AuthenticatorsApi(DEFAULT_CONFIG).authenticatorsAdminStaticDestroy({
|
return new AuthenticatorsApi(DEFAULT_CONFIG).authenticatorsAdminStaticDestroy({
|
||||||
id: device.pk,
|
id: device.pk,
|
||||||
});
|
});
|
||||||
|
|
|
@ -39,13 +39,13 @@ export class MFADeviceForm extends ModelForm<Device, number> {
|
||||||
sMSDeviceRequest: device,
|
sMSDeviceRequest: device,
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
case "otp_totp.TOTPDevice":
|
case "authentik_stages_authenticator_totp.TOTPDevice":
|
||||||
await new AuthenticatorsApi(DEFAULT_CONFIG).authenticatorsTotpUpdate({
|
await new AuthenticatorsApi(DEFAULT_CONFIG).authenticatorsTotpUpdate({
|
||||||
id: this.instance?.pk,
|
id: this.instance?.pk,
|
||||||
tOTPDeviceRequest: device,
|
tOTPDeviceRequest: device,
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
case "otp_static.StaticDevice":
|
case "authentik_stages_authenticator_static.StaticDevice":
|
||||||
await new AuthenticatorsApi(DEFAULT_CONFIG).authenticatorsStaticUpdate({
|
await new AuthenticatorsApi(DEFAULT_CONFIG).authenticatorsStaticUpdate({
|
||||||
id: this.instance?.pk,
|
id: this.instance?.pk,
|
||||||
staticDeviceRequest: device,
|
staticDeviceRequest: device,
|
||||||
|
|
|
@ -24,9 +24,9 @@ export function stageToAuthenticatorName(stage: UserSetting): string {
|
||||||
|
|
||||||
export function deviceTypeName(device: Device): string {
|
export function deviceTypeName(device: Device): string {
|
||||||
switch (device.type) {
|
switch (device.type) {
|
||||||
case "otp_static.StaticDevice":
|
case "authentik_stages_authenticator_static.StaticDevice":
|
||||||
return msg("Static tokens");
|
return msg("Static tokens");
|
||||||
case "otp_totp.TOTPDevice":
|
case "authentik_stages_authenticator_totp.TOTPDevice":
|
||||||
return msg("TOTP Device");
|
return msg("TOTP Device");
|
||||||
default:
|
default:
|
||||||
return device.verboseName;
|
return device.verboseName;
|
||||||
|
@ -102,11 +102,11 @@ export class MFADevicesPage extends Table<Device> {
|
||||||
return new AuthenticatorsApi(DEFAULT_CONFIG).authenticatorsSmsDestroy({
|
return new AuthenticatorsApi(DEFAULT_CONFIG).authenticatorsSmsDestroy({
|
||||||
id: device.pk,
|
id: device.pk,
|
||||||
});
|
});
|
||||||
case "otp_totp.TOTPDevice":
|
case "authentik_stages_authenticator_totp.TOTPDevice":
|
||||||
return new AuthenticatorsApi(DEFAULT_CONFIG).authenticatorsTotpDestroy({
|
return new AuthenticatorsApi(DEFAULT_CONFIG).authenticatorsTotpDestroy({
|
||||||
id: device.pk,
|
id: device.pk,
|
||||||
});
|
});
|
||||||
case "otp_static.StaticDevice":
|
case "authentik_stages_authenticator_static.StaticDevice":
|
||||||
return new AuthenticatorsApi(DEFAULT_CONFIG).authenticatorsStaticDestroy({
|
return new AuthenticatorsApi(DEFAULT_CONFIG).authenticatorsStaticDestroy({
|
||||||
id: device.pk,
|
id: device.pk,
|
||||||
});
|
});
|
||||||
|
|
Reference in New Issue