Skip to content

Commit

Permalink
stages/authenticator: vendor otp (#6741)
Browse files Browse the repository at this point in the history
* initial import

Signed-off-by: Jens Langhammer <[email protected]>

* update imports

Signed-off-by: Jens Langhammer <[email protected]>

* remove email and hotp for now

Signed-off-by: Jens Langhammer <[email protected]>

* remove things we don't need and clean up

Signed-off-by: Jens Langhammer <[email protected]>

* initial merge static

Signed-off-by: Jens Langhammer <[email protected]>

* initial merge totp

Signed-off-by: Jens Langhammer <[email protected]>

* more fixes

Signed-off-by: Jens Langhammer <[email protected]>

* fix migrations

Signed-off-by: Jens Langhammer <[email protected]>

* update webui

Signed-off-by: Jens Langhammer <[email protected]>

* add system migration

Signed-off-by: Jens Langhammer <[email protected]>

* more cleanup, add doctests to test_runner

Signed-off-by: Jens Langhammer <[email protected]>

* more cleanup

Signed-off-by: Jens Langhammer <[email protected]>

* fixup more lint

Signed-off-by: Jens Langhammer <[email protected]>

* cleanup last tests

Signed-off-by: Jens Langhammer <[email protected]>

* update docstrings

Signed-off-by: Jens Langhammer <[email protected]>

* fix tests

Signed-off-by: Jens Langhammer <[email protected]>

* implement SerializerModel

Signed-off-by: Jens Langhammer <[email protected]>

* fix web format

Signed-off-by: Jens Langhammer <[email protected]>

---------

Signed-off-by: Jens Langhammer <[email protected]>
  • Loading branch information
BeryJu authored Sep 4, 2023
1 parent 3f12c7c commit 6612f72
Show file tree
Hide file tree
Showing 45 changed files with 2,046 additions and 107 deletions.
4 changes: 2 additions & 2 deletions authentik/core/api/devices.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,4 @@
"""Authenticator Devices API Views"""
from django_otp import device_classes, devices_for_user
from django_otp.models import Device
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import OpenApiParameter, extend_schema
from rest_framework.fields import BooleanField, CharField, IntegerField, SerializerMethodField
Expand All @@ -10,6 +8,8 @@
from rest_framework.viewsets import ViewSet

from authentik.core.api.utils import MetaNameSerializer
from authentik.stages.authenticator import device_classes, devices_for_user
from authentik.stages.authenticator.models import Device


class DeviceSerializer(MetaNameSerializer):
Expand Down
4 changes: 2 additions & 2 deletions authentik/core/tests/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,10 @@ def create_test_admin_user(name: Optional[str] = None, **kwargs) -> User:
"""Generate a test-admin user"""
uid = generate_id(20) if not name else name
group = Group.objects.create(name=uid, is_superuser=True)
kwargs.setdefault("email", f"{uid}@goauthentik.io")
kwargs.setdefault("username", uid)
user: User = User.objects.create(
username=uid,
name=uid,
email=f"{uid}@goauthentik.io",
**kwargs,
)
user.set_password(uid)
Expand Down
34 changes: 10 additions & 24 deletions authentik/enterprise/policy.py
Original file line number Diff line number Diff line change
@@ -1,44 +1,30 @@
"""Enterprise license policies"""
from typing import Optional

from rest_framework.serializers import BaseSerializer

from authentik.core.models import User, UserTypes
from authentik.enterprise.models import LicenseKey
from authentik.policies.models import Policy
from authentik.policies.types import PolicyRequest, PolicyResult
from authentik.policies.views import PolicyAccessView


class EnterprisePolicy(Policy):
"""Check that a user is correctly licensed for the request"""

@property
def component(self) -> str:
return ""

@property
def serializer(self) -> type[BaseSerializer]:
raise NotImplementedError

def passes(self, request: PolicyRequest) -> PolicyResult:
if not LicenseKey.get_total().is_valid():
return PolicyResult(False)
if request.user.type != UserTypes.INTERNAL:
return PolicyResult(False)
return PolicyResult(True)


class EnterprisePolicyAccessView(PolicyAccessView):
"""PolicyAccessView which also checks enterprise licensing"""

def check_license(self):
"""Check license"""
if not LicenseKey.get_total().is_valid():
return False
if self.request.user.type != UserTypes.INTERNAL:
return False
return True

def user_has_access(self, user: Optional[User] = None) -> PolicyResult:
user = user or self.request.user
request = PolicyRequest(user)
request.http_request = self.request
result = super().user_has_access(user)
enterprise_result = EnterprisePolicy().passes(request)
if not enterprise_result.passing:
enterprise_result = self.check_license()
if not enterprise_result:
return enterprise_result
return result

Expand Down
2 changes: 1 addition & 1 deletion authentik/events/middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@
from django.db.models import Model
from django.db.models.signals import m2m_changed, post_save, pre_delete
from django.http import HttpRequest, HttpResponse
from django_otp.plugins.otp_static.models import StaticToken
from guardian.models import UserObjectPermission

from authentik.core.models import (
Expand All @@ -30,6 +29,7 @@
from authentik.policies.models import Policy, PolicyBindingModel
from authentik.providers.oauth2.models import AccessToken, AuthorizationCode, RefreshToken
from authentik.providers.scim.models import SCIMGroup, SCIMUser
from authentik.stages.authenticator_static.models import StaticToken

IGNORED_MODELS = (
Event,
Expand Down
2 changes: 1 addition & 1 deletion authentik/lib/expression/evaluator.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@

from cachetools import TLRUCache, cached
from django.core.exceptions import FieldError
from django_otp import devices_for_user
from guardian.shortcuts import get_anonymous_user
from rest_framework.serializers import ValidationError
from sentry_sdk.hub import Hub
Expand All @@ -20,6 +19,7 @@
from authentik.policies.models import Policy, PolicyBinding
from authentik.policies.process import PolicyProcess
from authentik.policies.types import PolicyRequest, PolicyResult
from authentik.stages.authenticator import devices_for_user

LOGGER = get_logger()

Expand Down
1 change: 1 addition & 0 deletions authentik/root/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@
"authentik.sources.oauth",
"authentik.sources.plex",
"authentik.sources.saml",
"authentik.stages.authenticator",
"authentik.stages.authenticator_duo",
"authentik.stages.authenticator_sms",
"authentik.stages.authenticator_static",
Expand Down
2 changes: 1 addition & 1 deletion authentik/root/test_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ def __init__(self, verbosity=1, failfast=False, keepdb=False, **kwargs):
self.failfast = failfast
self.keepdb = keepdb

self.args = ["-vv", "--full-trace"]
self.args = []
if self.failfast:
self.args.append("--exitfirst")
if self.keepdb:
Expand Down
129 changes: 129 additions & 0 deletions authentik/stages/authenticator/__init__.py
Original file line number Diff line number Diff line change
@@ -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
10 changes: 10 additions & 0 deletions authentik/stages/authenticator/apps.py
Original file line number Diff line number Diff line change
@@ -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"
Loading

0 comments on commit 6612f72

Please sign in to comment.