Compare commits
104 Commits
events/imp
...
enterprise
Author | SHA1 | Date | |
---|---|---|---|
42a99e3672 | |||
4e501f2fbf | |||
1cca629464 | |||
4efdc3113e | |||
5a9b0f7b7a | |||
395ccc5af1 | |||
c8ac4fcdd6 | |||
53c36394e9 | |||
c7fe987c5a | |||
e48739c8a0 | |||
b2ee585c43 | |||
97e8ea8e76 | |||
1f1e0c9db1 | |||
ca47a803fe | |||
c606eb53b0 | |||
62357133b0 | |||
99d2d91257 | |||
69d9363fce | |||
cfc7f6b993 | |||
bebbbe9b90 | |||
188d3c69c1 | |||
877f312145 | |||
f471a98bc7 | |||
e874cfc21d | |||
ec7bdf74aa | |||
e87bc94b95 | |||
a3865abaa9 | |||
7100d3c674 | |||
c0c2d2ad3c | |||
dc287989db | |||
03204f6943 | |||
fcd369e466 | |||
cb79407bc1 | |||
04a88daf34 | |||
c6a49da5c3 | |||
bfeeecf3fa | |||
d86b5e7c8a | |||
a95776891e | |||
031158fdba | |||
b2fbb92498 | |||
b1b6bf1a19 | |||
179d9d0721 | |||
8e94d58851 | |||
026669cfce | |||
c83cea6963 | |||
8e01cc2df8 | |||
279cec203d | |||
41c5030c1e | |||
3206fdb7ef | |||
d7c0868eef | |||
7d96a89697 | |||
dfb0007777 | |||
816d9668eb | |||
371d35ec06 | |||
664d3593ca | |||
7acd27eea8 | |||
83550dc50d | |||
c272dd70fd | |||
ae1d82dc69 | |||
dd42eeab62 | |||
680db9bae6 | |||
31b72751bc | |||
8210067479 | |||
423911d974 | |||
d4ca070d76 | |||
db1e8b291f | |||
44ff6fce23 | |||
085c22a41a | |||
fb2887fa4b | |||
ed41eb66de | |||
ee8122baa7 | |||
f0d70eef6f | |||
ff966d763b | |||
e00b68cafe | |||
bf4e8dbedc | |||
d09b7757b6 | |||
ca2f0439f6 | |||
27b7b0b0e7 | |||
88073305eb | |||
37657e47a3 | |||
0d649a70c9 | |||
7ec3055018 | |||
50ffce87c4 | |||
a4393ac9f0 | |||
e235c854a5 | |||
910b69f89d | |||
f89cc98014 | |||
91a675a5a1 | |||
71be3acd1a | |||
0b6ab171ce | |||
0c73572b0c | |||
03d0899a76 | |||
91f79c97d8 | |||
19324c61a3 | |||
d297733614 | |||
f201f41a1b | |||
f58f679171 | |||
1bea5e38a1 | |||
4d1c63e7fa | |||
e341032bf9 | |||
e3ff242956 | |||
c6756bf809 | |||
cf9b7eaa64 | |||
53d8f9bd8c |
@ -1,5 +1,5 @@
|
||||
[bumpversion]
|
||||
current_version = 2025.6.2
|
||||
current_version = 2025.6.3
|
||||
tag = True
|
||||
commit = True
|
||||
parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)(?:-(?P<rc_t>[a-zA-Z-]+)(?P<rc_n>[1-9]\\d*))?
|
||||
|
@ -38,6 +38,8 @@ jobs:
|
||||
# Needed for attestation
|
||||
id-token: write
|
||||
attestations: write
|
||||
# Needed for checkout
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: docker/setup-qemu-action@v3.6.0
|
||||
|
3
.github/workflows/ci-main-daily.yml
vendored
3
.github/workflows/ci-main-daily.yml
vendored
@ -9,14 +9,15 @@ on:
|
||||
|
||||
jobs:
|
||||
test-container:
|
||||
if: ${{ github.repository != 'goauthentik/authentik-internal' }}
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
version:
|
||||
- docs
|
||||
- version-2025-4
|
||||
- version-2025-2
|
||||
- version-2024-12
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- run: |
|
||||
|
4
.github/workflows/ci-main.yml
vendored
4
.github/workflows/ci-main.yml
vendored
@ -247,11 +247,13 @@ jobs:
|
||||
# Needed for attestation
|
||||
id-token: write
|
||||
attestations: write
|
||||
# Needed for checkout
|
||||
contents: read
|
||||
needs: ci-core-mark
|
||||
uses: ./.github/workflows/_reusable-docker-build.yaml
|
||||
secrets: inherit
|
||||
with:
|
||||
image_name: ghcr.io/goauthentik/dev-server
|
||||
image_name: ${{ github.repository == 'goauthentik/authentik-internal' && 'ghcr.io/goauthentik/internal-server' || 'ghcr.io/goauthentik/dev-server' }}
|
||||
release: false
|
||||
pr-comment:
|
||||
needs:
|
||||
|
1
.github/workflows/ci-outpost.yml
vendored
1
.github/workflows/ci-outpost.yml
vendored
@ -59,6 +59,7 @@ jobs:
|
||||
with:
|
||||
jobs: ${{ toJSON(needs) }}
|
||||
build-container:
|
||||
if: ${{ github.repository != 'goauthentik/authentik-internal' }}
|
||||
timeout-minutes: 120
|
||||
needs:
|
||||
- ci-outpost-mark
|
||||
|
2
.github/workflows/ci-website.yml
vendored
2
.github/workflows/ci-website.yml
vendored
@ -63,6 +63,7 @@ jobs:
|
||||
working-directory: website/
|
||||
run: npm run ${{ matrix.job }}
|
||||
build-container:
|
||||
if: ${{ github.repository != 'goauthentik/authentik-internal' }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
# Needed to upload container images to ghcr.io
|
||||
@ -122,3 +123,4 @@ jobs:
|
||||
- uses: re-actors/alls-green@release/v1
|
||||
with:
|
||||
jobs: ${{ toJSON(needs) }}
|
||||
allowed-skips: ${{ github.repository == 'goauthentik/authentik-internal' && 'build-container' || '[]' }}
|
||||
|
2
.github/workflows/codeql-analysis.yml
vendored
2
.github/workflows/codeql-analysis.yml
vendored
@ -2,7 +2,7 @@ name: "CodeQL"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, "*", next, version*]
|
||||
branches: [main, next, version*]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
schedule:
|
||||
|
21
.github/workflows/repo-mirror-cleanup.yml
vendored
Normal file
21
.github/workflows/repo-mirror-cleanup.yml
vendored
Normal file
@ -0,0 +1,21 @@
|
||||
name: "authentik-repo-mirror-cleanup"
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
to_internal:
|
||||
if: ${{ github.repository != 'goauthentik/authentik-internal' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- if: ${{ env.MIRROR_KEY != '' }}
|
||||
uses: BeryJu/repository-mirroring-action@5cf300935bc2e068f73ea69bcc411a8a997208eb
|
||||
with:
|
||||
target_repo_url: git@github.com:goauthentik/authentik-internal.git
|
||||
ssh_private_key: ${{ secrets.GH_MIRROR_KEY }}
|
||||
args: --tags --force --prune
|
||||
env:
|
||||
MIRROR_KEY: ${{ secrets.GH_MIRROR_KEY }}
|
9
.github/workflows/repo-mirror.yml
vendored
9
.github/workflows/repo-mirror.yml
vendored
@ -11,11 +11,10 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- if: ${{ env.MIRROR_KEY != '' }}
|
||||
uses: pixta-dev/repository-mirroring-action@v1
|
||||
uses: BeryJu/repository-mirroring-action@5cf300935bc2e068f73ea69bcc411a8a997208eb
|
||||
with:
|
||||
target_repo_url:
|
||||
git@github.com:goauthentik/authentik-internal.git
|
||||
ssh_private_key:
|
||||
${{ secrets.GH_MIRROR_KEY }}
|
||||
target_repo_url: git@github.com:goauthentik/authentik-internal.git
|
||||
ssh_private_key: ${{ secrets.GH_MIRROR_KEY }}
|
||||
args: --tags --force
|
||||
env:
|
||||
MIRROR_KEY: ${{ secrets.GH_MIRROR_KEY }}
|
||||
|
@ -16,6 +16,7 @@ env:
|
||||
|
||||
jobs:
|
||||
compile:
|
||||
if: ${{ github.repository != 'goauthentik/authentik-internal' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- id: generate_token
|
||||
|
4
.vscode/settings.json
vendored
4
.vscode/settings.json
vendored
@ -6,13 +6,15 @@
|
||||
"!Context scalar",
|
||||
"!Enumerate sequence",
|
||||
"!Env scalar",
|
||||
"!Env sequence",
|
||||
"!Find sequence",
|
||||
"!Format sequence",
|
||||
"!If sequence",
|
||||
"!Index scalar",
|
||||
"!KeyOf scalar",
|
||||
"!Value scalar",
|
||||
"!AtIndex scalar"
|
||||
"!AtIndex scalar",
|
||||
"!ParseJSON scalar"
|
||||
],
|
||||
"typescript.preferences.importModuleSpecifier": "non-relative",
|
||||
"typescript.preferences.importModuleSpecifierEnding": "index",
|
||||
|
@ -75,7 +75,7 @@ RUN --mount=type=secret,id=GEOIPUPDATE_ACCOUNT_ID \
|
||||
/bin/sh -c "GEOIPUPDATE_LICENSE_KEY_FILE=/run/secrets/GEOIPUPDATE_LICENSE_KEY /usr/bin/entry.sh || echo 'Failed to get GeoIP database, disabling'; exit 0"
|
||||
|
||||
# Stage 4: Download uv
|
||||
FROM ghcr.io/astral-sh/uv:0.7.13 AS uv
|
||||
FROM ghcr.io/astral-sh/uv:0.7.17 AS uv
|
||||
# Stage 5: Base python image
|
||||
FROM ghcr.io/goauthentik/fips-python:3.13.5-slim-bookworm-fips AS python-base
|
||||
|
||||
|
6
Makefile
6
Makefile
@ -150,9 +150,9 @@ gen-client-ts: gen-clean-ts ## Build and install the authentik API for Typescri
|
||||
--additional-properties=npmVersion=${NPM_VERSION} \
|
||||
--git-repo-id authentik \
|
||||
--git-user-id goauthentik
|
||||
mkdir -p web/node_modules/@goauthentik/api
|
||||
cd ${PWD}/${GEN_API_TS} && npm i
|
||||
\cp -rf ${PWD}/${GEN_API_TS}/* web/node_modules/@goauthentik/api
|
||||
|
||||
cd ${PWD}/${GEN_API_TS} && npm link
|
||||
cd ${PWD}/web && npm link @goauthentik/api
|
||||
|
||||
gen-client-py: gen-clean-py ## Build and install the authentik API for Python
|
||||
docker run \
|
||||
|
@ -2,7 +2,7 @@
|
||||
|
||||
from os import environ
|
||||
|
||||
__version__ = "2025.6.2"
|
||||
__version__ = "2025.6.3"
|
||||
ENV_GIT_HASH_KEY = "GIT_BUILD_HASH"
|
||||
|
||||
|
||||
|
@ -37,6 +37,7 @@ entries:
|
||||
- attrs:
|
||||
attributes:
|
||||
env_null: !Env [bar-baz, null]
|
||||
json_parse: !ParseJSON '{"foo": "bar"}'
|
||||
policy_pk1:
|
||||
!Format [
|
||||
"%s-%s",
|
||||
|
@ -35,6 +35,6 @@ def blueprint_tester(file_name: Path) -> Callable:
|
||||
|
||||
|
||||
for blueprint_file in Path("blueprints/").glob("**/*.yaml"):
|
||||
if "local" in str(blueprint_file):
|
||||
if "local" in str(blueprint_file) or "testing" in str(blueprint_file):
|
||||
continue
|
||||
setattr(TestPackaged, f"test_blueprint_{blueprint_file}", blueprint_tester(blueprint_file))
|
||||
|
@ -5,7 +5,6 @@ from collections.abc import Callable
|
||||
from django.apps import apps
|
||||
from django.test import TestCase
|
||||
|
||||
from authentik.blueprints.v1.importer import is_model_allowed
|
||||
from authentik.lib.models import SerializerModel
|
||||
from authentik.providers.oauth2.models import RefreshToken
|
||||
|
||||
@ -22,10 +21,13 @@ def serializer_tester_factory(test_model: type[SerializerModel]) -> Callable:
|
||||
return
|
||||
model_class = test_model()
|
||||
self.assertTrue(isinstance(model_class, SerializerModel))
|
||||
# Models that have subclasses don't have to have a serializer
|
||||
if len(test_model.__subclasses__()) > 0:
|
||||
return
|
||||
self.assertIsNotNone(model_class.serializer)
|
||||
if model_class.serializer.Meta().model == RefreshToken:
|
||||
return
|
||||
self.assertEqual(model_class.serializer.Meta().model, test_model)
|
||||
self.assertTrue(issubclass(test_model, model_class.serializer.Meta().model))
|
||||
|
||||
return tester
|
||||
|
||||
@ -34,6 +36,6 @@ for app in apps.get_app_configs():
|
||||
if not app.label.startswith("authentik"):
|
||||
continue
|
||||
for model in app.get_models():
|
||||
if not is_model_allowed(model):
|
||||
if not issubclass(model, SerializerModel):
|
||||
continue
|
||||
setattr(TestModels, f"test_{app.label}_{model.__name__}", serializer_tester_factory(model))
|
||||
|
@ -215,6 +215,7 @@ class TestBlueprintsV1(TransactionTestCase):
|
||||
},
|
||||
"nested_context": "context-nested-value",
|
||||
"env_null": None,
|
||||
"json_parse": {"foo": "bar"},
|
||||
"at_index_sequence": "foo",
|
||||
"at_index_sequence_default": "non existent",
|
||||
"at_index_mapping": 2,
|
||||
|
@ -6,6 +6,7 @@ from copy import copy
|
||||
from dataclasses import asdict, dataclass, field, is_dataclass
|
||||
from enum import Enum
|
||||
from functools import reduce
|
||||
from json import JSONDecodeError, loads
|
||||
from operator import ixor
|
||||
from os import getenv
|
||||
from typing import Any, Literal, Union
|
||||
@ -291,6 +292,22 @@ class Context(YAMLTag):
|
||||
return value
|
||||
|
||||
|
||||
class ParseJSON(YAMLTag):
|
||||
"""Parse JSON from context/env/etc value"""
|
||||
|
||||
raw: str
|
||||
|
||||
def __init__(self, loader: "BlueprintLoader", node: ScalarNode) -> None:
|
||||
super().__init__()
|
||||
self.raw = node.value
|
||||
|
||||
def resolve(self, entry: BlueprintEntry, blueprint: Blueprint) -> Any:
|
||||
try:
|
||||
return loads(self.raw)
|
||||
except JSONDecodeError as exc:
|
||||
raise EntryInvalidError.from_entry(exc, entry) from exc
|
||||
|
||||
|
||||
class Format(YAMLTag):
|
||||
"""Format a string"""
|
||||
|
||||
@ -666,6 +683,7 @@ class BlueprintLoader(SafeLoader):
|
||||
self.add_constructor("!Value", Value)
|
||||
self.add_constructor("!Index", Index)
|
||||
self.add_constructor("!AtIndex", AtIndex)
|
||||
self.add_constructor("!ParseJSON", ParseJSON)
|
||||
|
||||
|
||||
class EntryInvalidError(SentryIgnoredException):
|
||||
|
@ -43,6 +43,7 @@ from authentik.core.models import (
|
||||
)
|
||||
from authentik.enterprise.license import LicenseKey
|
||||
from authentik.enterprise.models import LicenseUsage
|
||||
from authentik.enterprise.providers.apple_psso.models import AppleNonce
|
||||
from authentik.enterprise.providers.google_workspace.models import (
|
||||
GoogleWorkspaceProviderGroup,
|
||||
GoogleWorkspaceProviderUser,
|
||||
@ -135,6 +136,7 @@ def excluded_models() -> list[type[Model]]:
|
||||
EndpointDeviceConnection,
|
||||
DeviceToken,
|
||||
StreamEvent,
|
||||
AppleNonce,
|
||||
)
|
||||
|
||||
|
||||
|
@ -1,8 +1,6 @@
|
||||
"""Authenticator Devices API Views"""
|
||||
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from drf_spectacular.types import OpenApiTypes
|
||||
from drf_spectacular.utils import OpenApiParameter, extend_schema
|
||||
from drf_spectacular.utils import extend_schema
|
||||
from guardian.shortcuts import get_objects_for_user
|
||||
from rest_framework.fields import (
|
||||
BooleanField,
|
||||
@ -15,6 +13,7 @@ from rest_framework.request import Request
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.viewsets import ViewSet
|
||||
|
||||
from authentik.core.api.users import ParamUserSerializer
|
||||
from authentik.core.api.utils import MetaNameSerializer
|
||||
from authentik.enterprise.stages.authenticator_endpoint_gdtc.models import EndpointDevice
|
||||
from authentik.stages.authenticator import device_classes, devices_for_user
|
||||
@ -23,7 +22,7 @@ from authentik.stages.authenticator_webauthn.models import WebAuthnDevice
|
||||
|
||||
|
||||
class DeviceSerializer(MetaNameSerializer):
|
||||
"""Serializer for Duo authenticator devices"""
|
||||
"""Serializer for authenticator devices"""
|
||||
|
||||
pk = CharField()
|
||||
name = CharField()
|
||||
@ -33,22 +32,27 @@ class DeviceSerializer(MetaNameSerializer):
|
||||
last_updated = DateTimeField(read_only=True)
|
||||
last_used = DateTimeField(read_only=True, allow_null=True)
|
||||
extra_description = SerializerMethodField()
|
||||
external_id = SerializerMethodField()
|
||||
|
||||
def get_type(self, instance: Device) -> str:
|
||||
"""Get type of device"""
|
||||
return instance._meta.label
|
||||
|
||||
def get_extra_description(self, instance: Device) -> str:
|
||||
def get_extra_description(self, instance: Device) -> str | None:
|
||||
"""Get extra description"""
|
||||
if isinstance(instance, WebAuthnDevice):
|
||||
return (
|
||||
instance.device_type.description
|
||||
if instance.device_type
|
||||
else _("Extra description not available")
|
||||
)
|
||||
return instance.device_type.description if instance.device_type else None
|
||||
if isinstance(instance, EndpointDevice):
|
||||
return instance.data.get("deviceSignals", {}).get("deviceModel")
|
||||
return ""
|
||||
return None
|
||||
|
||||
def get_external_id(self, instance: Device) -> str | None:
|
||||
"""Get external Device ID"""
|
||||
if isinstance(instance, WebAuthnDevice):
|
||||
return instance.device_type.aaguid if instance.device_type else None
|
||||
if isinstance(instance, EndpointDevice):
|
||||
return instance.data.get("deviceSignals", {}).get("deviceModel")
|
||||
return None
|
||||
|
||||
|
||||
class DeviceViewSet(ViewSet):
|
||||
@ -57,7 +61,6 @@ class DeviceViewSet(ViewSet):
|
||||
serializer_class = DeviceSerializer
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
@extend_schema(responses={200: DeviceSerializer(many=True)})
|
||||
def list(self, request: Request) -> Response:
|
||||
"""Get all devices for current user"""
|
||||
devices = devices_for_user(request.user)
|
||||
@ -79,18 +82,11 @@ class AdminDeviceViewSet(ViewSet):
|
||||
yield from device_set
|
||||
|
||||
@extend_schema(
|
||||
parameters=[
|
||||
OpenApiParameter(
|
||||
name="user",
|
||||
location=OpenApiParameter.QUERY,
|
||||
type=OpenApiTypes.INT,
|
||||
)
|
||||
],
|
||||
parameters=[ParamUserSerializer],
|
||||
responses={200: DeviceSerializer(many=True)},
|
||||
)
|
||||
def list(self, request: Request) -> Response:
|
||||
"""Get all devices for current user"""
|
||||
kwargs = {}
|
||||
if "user" in request.query_params:
|
||||
kwargs = {"user": request.query_params["user"]}
|
||||
return Response(DeviceSerializer(self.get_devices(**kwargs), many=True).data)
|
||||
args = ParamUserSerializer(data=request.query_params)
|
||||
args.is_valid(raise_exception=True)
|
||||
return Response(DeviceSerializer(self.get_devices(**args.validated_data), many=True).data)
|
||||
|
@ -90,6 +90,12 @@ from authentik.stages.email.utils import TemplateEmailMessage
|
||||
LOGGER = get_logger()
|
||||
|
||||
|
||||
class ParamUserSerializer(PassiveSerializer):
|
||||
"""Partial serializer for query parameters to select a user"""
|
||||
|
||||
user = PrimaryKeyRelatedField(queryset=User.objects.all().exclude_anonymous(), required=False)
|
||||
|
||||
|
||||
class UserGroupSerializer(ModelSerializer):
|
||||
"""Simplified Group Serializer for user's groups"""
|
||||
|
||||
@ -401,7 +407,7 @@ class UserViewSet(UsedByMixin, ModelViewSet):
|
||||
StrField(User, "path"),
|
||||
BoolField(User, "is_active", nullable=True),
|
||||
ChoiceSearchField(User, "type"),
|
||||
JSONSearchField(User, "attributes"),
|
||||
JSONSearchField(User, "attributes", suggest_nested=False),
|
||||
]
|
||||
|
||||
def get_queryset(self):
|
||||
|
@ -2,6 +2,7 @@
|
||||
|
||||
from typing import Any
|
||||
|
||||
from django.db import models
|
||||
from django.db.models import Model
|
||||
from drf_spectacular.extensions import OpenApiSerializerFieldExtension
|
||||
from drf_spectacular.plumbing import build_basic_type
|
||||
@ -30,7 +31,27 @@ def is_dict(value: Any):
|
||||
raise ValidationError("Value must be a dictionary, and not have any duplicate keys.")
|
||||
|
||||
|
||||
class JSONDictField(JSONField):
|
||||
"""JSON Field which only allows dictionaries"""
|
||||
|
||||
default_validators = [is_dict]
|
||||
|
||||
|
||||
class JSONExtension(OpenApiSerializerFieldExtension):
|
||||
"""Generate API Schema for JSON fields as"""
|
||||
|
||||
target_class = "authentik.core.api.utils.JSONDictField"
|
||||
|
||||
def map_serializer_field(self, auto_schema, direction):
|
||||
return build_basic_type(OpenApiTypes.OBJECT)
|
||||
|
||||
|
||||
class ModelSerializer(BaseModelSerializer):
|
||||
|
||||
# By default, JSON fields we have are used to store dictionaries
|
||||
serializer_field_mapping = BaseModelSerializer.serializer_field_mapping.copy()
|
||||
serializer_field_mapping[models.JSONField] = JSONDictField
|
||||
|
||||
def create(self, validated_data):
|
||||
instance = super().create(validated_data)
|
||||
|
||||
@ -71,21 +92,6 @@ class ModelSerializer(BaseModelSerializer):
|
||||
return instance
|
||||
|
||||
|
||||
class JSONDictField(JSONField):
|
||||
"""JSON Field which only allows dictionaries"""
|
||||
|
||||
default_validators = [is_dict]
|
||||
|
||||
|
||||
class JSONExtension(OpenApiSerializerFieldExtension):
|
||||
"""Generate API Schema for JSON fields as"""
|
||||
|
||||
target_class = "authentik.core.api.utils.JSONDictField"
|
||||
|
||||
def map_serializer_field(self, auto_schema, direction):
|
||||
return build_basic_type(OpenApiTypes.OBJECT)
|
||||
|
||||
|
||||
class PassiveSerializer(Serializer):
|
||||
"""Base serializer class which doesn't implement create/update methods"""
|
||||
|
||||
|
@ -11,6 +11,7 @@ from authentik.core.expression.exceptions import SkipObjectException
|
||||
from authentik.core.models import User
|
||||
from authentik.events.models import Event, EventAction
|
||||
from authentik.lib.expression.evaluator import BaseEvaluator
|
||||
from authentik.lib.utils.errors import exception_to_string
|
||||
from authentik.policies.types import PolicyRequest
|
||||
|
||||
PROPERTY_MAPPING_TIME = Histogram(
|
||||
@ -68,11 +69,12 @@ class PropertyMappingEvaluator(BaseEvaluator):
|
||||
# For dry-run requests we don't save exceptions
|
||||
if self.dry_run:
|
||||
return
|
||||
error_string = exception_to_string(exc)
|
||||
event = Event.new(
|
||||
EventAction.PROPERTY_MAPPING_EXCEPTION,
|
||||
expression=expression_source,
|
||||
message="Failed to execute property mapping",
|
||||
).with_exception(exc)
|
||||
message=error_string,
|
||||
)
|
||||
if "request" in self._context:
|
||||
req: PolicyRequest = self._context["request"]
|
||||
if req.http_request:
|
||||
|
@ -13,7 +13,6 @@ class Command(TenantCommand):
|
||||
parser.add_argument("usernames", nargs="*", type=str)
|
||||
|
||||
def handle_per_tenant(self, **options):
|
||||
print(options)
|
||||
new_type = UserTypes(options["type"])
|
||||
qs = (
|
||||
User.objects.exclude_anonymous()
|
||||
|
@ -1082,6 +1082,12 @@ class AuthenticatedSession(SerializerModel):
|
||||
|
||||
user = models.ForeignKey(User, on_delete=models.CASCADE)
|
||||
|
||||
@property
|
||||
def serializer(self) -> type[Serializer]:
|
||||
from authentik.core.api.authenticated_sessions import AuthenticatedSessionSerializer
|
||||
|
||||
return AuthenticatedSessionSerializer
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("Authenticated Session")
|
||||
verbose_name_plural = _("Authenticated Sessions")
|
||||
|
32
authentik/enterprise/providers/apple_psso/api/providers.py
Normal file
32
authentik/enterprise/providers/apple_psso/api/providers.py
Normal file
@ -0,0 +1,32 @@
|
||||
"""Apple Platform SSO Provider API Views"""
|
||||
|
||||
from rest_framework.viewsets import ModelViewSet
|
||||
|
||||
from authentik.core.api.providers import ProviderSerializer
|
||||
from authentik.core.api.used_by import UsedByMixin
|
||||
from authentik.enterprise.api import EnterpriseRequiredMixin
|
||||
from authentik.enterprise.providers.apple_psso.models import ApplePlatformSSOProvider
|
||||
|
||||
|
||||
class ApplePlatformSSOProviderSerializer(EnterpriseRequiredMixin, ProviderSerializer):
|
||||
"""ApplePlatformSSOProvider Serializer"""
|
||||
|
||||
class Meta:
|
||||
model = ApplePlatformSSOProvider
|
||||
fields = [
|
||||
"pk",
|
||||
"name",
|
||||
]
|
||||
extra_kwargs = {}
|
||||
|
||||
|
||||
class ApplePlatformSSOProviderViewSet(UsedByMixin, ModelViewSet):
|
||||
"""ApplePlatformSSOProvider Viewset"""
|
||||
|
||||
queryset = ApplePlatformSSOProvider.objects.all()
|
||||
serializer_class = ApplePlatformSSOProviderSerializer
|
||||
filterset_fields = [
|
||||
"name",
|
||||
]
|
||||
search_fields = ["name"]
|
||||
ordering = ["name"]
|
13
authentik/enterprise/providers/apple_psso/apps.py
Normal file
13
authentik/enterprise/providers/apple_psso/apps.py
Normal file
@ -0,0 +1,13 @@
|
||||
from authentik.enterprise.apps import EnterpriseConfig
|
||||
|
||||
|
||||
class AuthentikEnterpriseProviderApplePSSOConfig(EnterpriseConfig):
|
||||
|
||||
name = "authentik.enterprise.providers.apple_psso"
|
||||
label = "authentik_providers_apple_psso"
|
||||
verbose_name = "authentik Enterprise.Providers.Apple Platform SSO"
|
||||
default = True
|
||||
mountpoints = {
|
||||
"authentik.enterprise.providers.apple_psso.urls": "endpoint/apple/sso/",
|
||||
"authentik.enterprise.providers.apple_psso.urls_root": "",
|
||||
}
|
118
authentik/enterprise/providers/apple_psso/http.py
Normal file
118
authentik/enterprise/providers/apple_psso/http.py
Normal file
@ -0,0 +1,118 @@
|
||||
from base64 import urlsafe_b64encode
|
||||
from json import dumps
|
||||
from secrets import token_bytes
|
||||
|
||||
from cryptography.hazmat.backends import default_backend
|
||||
from cryptography.hazmat.primitives import hashes, serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import ec
|
||||
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
||||
from cryptography.hazmat.primitives.kdf.concatkdf import ConcatKDFHash
|
||||
from django.http import HttpResponse
|
||||
from jwcrypto.common import base64url_decode, base64url_encode
|
||||
|
||||
from authentik.enterprise.providers.apple_psso.models import AppleDevice
|
||||
|
||||
|
||||
def length_prefixed(data: bytes) -> bytes:
|
||||
length = len(data)
|
||||
return length.to_bytes(4, "big") + data
|
||||
|
||||
|
||||
def build_apu(public_key: ec.EllipticCurvePublicKey):
|
||||
# X9.63 representation: 0x04 || X || Y
|
||||
public_numbers = public_key.public_numbers()
|
||||
|
||||
x_bytes = public_numbers.x.to_bytes(32, "big")
|
||||
y_bytes = public_numbers.y.to_bytes(32, "big")
|
||||
|
||||
x963 = bytes([0x04]) + x_bytes + y_bytes
|
||||
|
||||
result = length_prefixed(b"APPLE") + length_prefixed(x963)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def encrypt_token_with_a256_gcm(body: dict, device_encryption_key: str, apv: bytes) -> str:
|
||||
ephemeral_key = ec.generate_private_key(curve=ec.SECP256R1())
|
||||
device_public_key = serialization.load_pem_public_key(
|
||||
device_encryption_key.encode(), backend=default_backend()
|
||||
)
|
||||
|
||||
shared_secret_z = ephemeral_key.exchange(ec.ECDH(), device_public_key)
|
||||
|
||||
apu = build_apu(ephemeral_key.public_key())
|
||||
|
||||
jwe_header = {
|
||||
"enc": "A256GCM",
|
||||
"kid": "ephemeralKey",
|
||||
"epk": {
|
||||
"x": base64url_encode(
|
||||
ephemeral_key.public_key().public_numbers().x.to_bytes(32, "big")
|
||||
),
|
||||
"y": base64url_encode(
|
||||
ephemeral_key.public_key().public_numbers().y.to_bytes(32, "big")
|
||||
),
|
||||
"kty": "EC",
|
||||
"crv": "P-256",
|
||||
},
|
||||
"typ": "platformsso-login-response+jwt",
|
||||
"alg": "ECDH-ES",
|
||||
"apu": base64url_encode(apu),
|
||||
"apv": base64url_encode(apv),
|
||||
}
|
||||
|
||||
party_u_info = length_prefixed(apu)
|
||||
party_v_info = length_prefixed(apv)
|
||||
supp_pub_info = (256).to_bytes(4, "big")
|
||||
|
||||
other_info = length_prefixed(b"A256GCM") + party_u_info + party_v_info + supp_pub_info
|
||||
|
||||
ckdf = ConcatKDFHash(
|
||||
algorithm=hashes.SHA256(),
|
||||
length=32,
|
||||
otherinfo=other_info,
|
||||
)
|
||||
|
||||
derived_key = ckdf.derive(shared_secret_z)
|
||||
|
||||
nonce = token_bytes(12)
|
||||
|
||||
header_json = dumps(jwe_header, separators=(",", ":")).encode()
|
||||
aad = urlsafe_b64encode(header_json).rstrip(b"=")
|
||||
|
||||
aesgcm = AESGCM(derived_key)
|
||||
ciphertext = aesgcm.encrypt(nonce, dumps(body).encode(), aad)
|
||||
|
||||
ciphertext_body = ciphertext[:-16]
|
||||
tag = ciphertext[-16:]
|
||||
|
||||
# base64url encoding
|
||||
protected_b64 = urlsafe_b64encode(header_json).rstrip(b"=")
|
||||
iv_b64 = urlsafe_b64encode(nonce).rstrip(b"=")
|
||||
ciphertext_b64 = urlsafe_b64encode(ciphertext_body).rstrip(b"=")
|
||||
tag_b64 = urlsafe_b64encode(tag).rstrip(b"=")
|
||||
|
||||
jwe_compact = b".".join(
|
||||
[
|
||||
protected_b64,
|
||||
b"",
|
||||
iv_b64,
|
||||
ciphertext_b64,
|
||||
tag_b64,
|
||||
]
|
||||
)
|
||||
return jwe_compact.decode()
|
||||
|
||||
|
||||
class JWEResponse(HttpResponse):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
data: dict,
|
||||
device: AppleDevice,
|
||||
apv: str,
|
||||
):
|
||||
super().__init__(
|
||||
content=encrypt_token_with_a256_gcm(data, device.encryption_key, base64url_decode(apv)),
|
||||
content_type="application/platformsso-login-response+jwt",
|
||||
)
|
@ -0,0 +1,36 @@
|
||||
# Generated by Django 5.1.11 on 2025-06-28 00:12
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
("authentik_providers_oauth2", "0028_migrate_session"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="ApplePlatformSSOProvider",
|
||||
fields=[
|
||||
(
|
||||
"oauth2provider_ptr",
|
||||
models.OneToOneField(
|
||||
auto_created=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
parent_link=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
to="authentik_providers_oauth2.oauth2provider",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"abstract": False,
|
||||
},
|
||||
bases=("authentik_providers_oauth2.oauth2provider",),
|
||||
),
|
||||
]
|
@ -0,0 +1,94 @@
|
||||
# Generated by Django 5.1.11 on 2025-06-28 15:50
|
||||
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("authentik_providers_apple_psso", "0001_initial"),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="AppleDevice",
|
||||
fields=[
|
||||
(
|
||||
"endpoint_uuid",
|
||||
models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False),
|
||||
),
|
||||
("signing_key", models.TextField()),
|
||||
("encryption_key", models.TextField()),
|
||||
("key_exchange_key", models.TextField()),
|
||||
("sign_key_id", models.TextField()),
|
||||
("enc_key_id", models.TextField()),
|
||||
("creation_time", models.DateTimeField(auto_now_add=True)),
|
||||
(
|
||||
"provider",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to="authentik_providers_apple_psso.appleplatformssoprovider",
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="AppleDeviceUser",
|
||||
fields=[
|
||||
("uuid", models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False)),
|
||||
("signing_key", models.TextField()),
|
||||
("encryption_key", models.TextField()),
|
||||
("sign_key_id", models.TextField()),
|
||||
("enc_key_id", models.TextField()),
|
||||
(
|
||||
"device",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to="authentik_providers_apple_psso.appledevice",
|
||||
),
|
||||
),
|
||||
(
|
||||
"user",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="appledevice",
|
||||
name="users",
|
||||
field=models.ManyToManyField(
|
||||
through="authentik_providers_apple_psso.AppleDeviceUser",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="AppleNonce",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.AutoField(
|
||||
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
|
||||
),
|
||||
),
|
||||
("expires", models.DateTimeField(default=None, null=True)),
|
||||
("expiring", models.BooleanField(default=True)),
|
||||
("nonce", models.TextField()),
|
||||
],
|
||||
options={
|
||||
"abstract": False,
|
||||
"indexes": [
|
||||
models.Index(fields=["expires"], name="authentik_p_expires_47d534_idx"),
|
||||
models.Index(fields=["expiring"], name="authentik_p_expirin_87253e_idx"),
|
||||
models.Index(
|
||||
fields=["expiring", "expires"], name="authentik_p_expirin_20a7c9_idx"
|
||||
),
|
||||
],
|
||||
},
|
||||
),
|
||||
]
|
@ -0,0 +1,34 @@
|
||||
# Generated by Django 5.1.11 on 2025-06-28 22:18
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
(
|
||||
"authentik_providers_apple_psso",
|
||||
"0002_appledevice_appledeviceuser_appledevice_users_and_more",
|
||||
),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RenameField(
|
||||
model_name="appledeviceuser",
|
||||
old_name="sign_key_id",
|
||||
new_name="enclave_key_id",
|
||||
),
|
||||
migrations.RenameField(
|
||||
model_name="appledeviceuser",
|
||||
old_name="signing_key",
|
||||
new_name="secure_enclave_key",
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name="appledeviceuser",
|
||||
name="enc_key_id",
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name="appledeviceuser",
|
||||
name="encryption_key",
|
||||
),
|
||||
]
|
85
authentik/enterprise/providers/apple_psso/models.py
Normal file
85
authentik/enterprise/providers/apple_psso/models.py
Normal file
@ -0,0 +1,85 @@
|
||||
from uuid import uuid4
|
||||
|
||||
from django.db import models
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from rest_framework.serializers import Serializer
|
||||
|
||||
from authentik.core.models import ExpiringModel, User
|
||||
from authentik.crypto.models import CertificateKeyPair
|
||||
from authentik.providers.oauth2.models import (
|
||||
ClientTypes,
|
||||
IssuerMode,
|
||||
OAuth2Provider,
|
||||
RedirectURI,
|
||||
RedirectURIMatchingMode,
|
||||
ScopeMapping,
|
||||
)
|
||||
|
||||
|
||||
class ApplePlatformSSOProvider(OAuth2Provider):
|
||||
"""Integrate with Apple Platform SSO"""
|
||||
|
||||
def set_oauth_defaults(self):
|
||||
"""Ensure all OAuth2-related settings are correct"""
|
||||
self.issuer_mode = IssuerMode.PER_PROVIDER
|
||||
self.client_type = ClientTypes.PUBLIC
|
||||
self.signing_key = CertificateKeyPair.objects.get(name="authentik Self-signed Certificate")
|
||||
self.include_claims_in_id_token = True
|
||||
scopes = ScopeMapping.objects.filter(
|
||||
managed__in=[
|
||||
"goauthentik.io/providers/oauth2/scope-openid",
|
||||
"goauthentik.io/providers/oauth2/scope-profile",
|
||||
"goauthentik.io/providers/oauth2/scope-email",
|
||||
"goauthentik.io/providers/oauth2/scope-offline_access",
|
||||
"goauthentik.io/providers/oauth2/scope-authentik_api",
|
||||
]
|
||||
)
|
||||
self.property_mappings.add(*list(scopes))
|
||||
self.redirect_uris = [
|
||||
RedirectURI(RedirectURIMatchingMode.STRICT, "io.goauthentik.endpoint:/oauth2redirect"),
|
||||
]
|
||||
|
||||
@property
|
||||
def component(self) -> str:
|
||||
return "ak-provider-apple-psso-form"
|
||||
|
||||
@property
|
||||
def serializer(self) -> type[Serializer]:
|
||||
from authentik.enterprise.providers.apple_psso.api.providers import (
|
||||
ApplePlatformSSOProviderSerializer,
|
||||
)
|
||||
|
||||
return ApplePlatformSSOProviderSerializer
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("Apple Platform SSO Provider")
|
||||
verbose_name_plural = _("Apple Platform SSO Providers")
|
||||
|
||||
|
||||
class AppleDevice(models.Model):
|
||||
|
||||
endpoint_uuid = models.UUIDField(default=uuid4, primary_key=True)
|
||||
|
||||
signing_key = models.TextField()
|
||||
encryption_key = models.TextField()
|
||||
key_exchange_key = models.TextField()
|
||||
sign_key_id = models.TextField()
|
||||
enc_key_id = models.TextField()
|
||||
creation_time = models.DateTimeField(auto_now_add=True)
|
||||
provider = models.ForeignKey(ApplePlatformSSOProvider, on_delete=models.CASCADE)
|
||||
users = models.ManyToManyField(User, through="AppleDeviceUser")
|
||||
|
||||
|
||||
class AppleDeviceUser(models.Model):
|
||||
|
||||
uuid = models.UUIDField(default=uuid4, primary_key=True)
|
||||
|
||||
device = models.ForeignKey(AppleDevice, on_delete=models.CASCADE)
|
||||
user = models.ForeignKey(User, on_delete=models.CASCADE)
|
||||
|
||||
secure_enclave_key = models.TextField()
|
||||
enclave_key_id = models.TextField()
|
||||
|
||||
|
||||
class AppleNonce(ExpiringModel):
|
||||
nonce = models.TextField()
|
15
authentik/enterprise/providers/apple_psso/urls.py
Normal file
15
authentik/enterprise/providers/apple_psso/urls.py
Normal file
@ -0,0 +1,15 @@
|
||||
from django.urls import path
|
||||
|
||||
from authentik.enterprise.providers.apple_psso.views.nonce import NonceView
|
||||
from authentik.enterprise.providers.apple_psso.views.register import (
|
||||
RegisterDeviceView,
|
||||
RegisterUserView,
|
||||
)
|
||||
from authentik.enterprise.providers.apple_psso.views.token import TokenView
|
||||
|
||||
urlpatterns = [
|
||||
path("token/", TokenView.as_view(), name="token"),
|
||||
path("nonce/", NonceView.as_view(), name="nonce"),
|
||||
path("register/device/", RegisterDeviceView.as_view(), name="register-device"),
|
||||
path("register/user/", RegisterUserView.as_view(), name="register-user"),
|
||||
]
|
7
authentik/enterprise/providers/apple_psso/urls_root.py
Normal file
7
authentik/enterprise/providers/apple_psso/urls_root.py
Normal file
@ -0,0 +1,7 @@
|
||||
from django.urls import path
|
||||
|
||||
from authentik.enterprise.providers.apple_psso.views.site_association import AppleAppSiteAssociation
|
||||
|
||||
urlpatterns = [
|
||||
path(".well-known/apple-app-site-association", AppleAppSiteAssociation.as_view(), name="asa"),
|
||||
]
|
25
authentik/enterprise/providers/apple_psso/views/nonce.py
Normal file
25
authentik/enterprise/providers/apple_psso/views/nonce.py
Normal file
@ -0,0 +1,25 @@
|
||||
from base64 import b64encode
|
||||
from datetime import timedelta
|
||||
from secrets import token_bytes
|
||||
|
||||
from django.http import HttpRequest, JsonResponse
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.utils.timezone import now
|
||||
from django.views import View
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
|
||||
from authentik.enterprise.providers.apple_psso.models import AppleNonce
|
||||
|
||||
|
||||
@method_decorator(csrf_exempt, name="dispatch")
|
||||
class NonceView(View):
|
||||
|
||||
def post(self, request: HttpRequest, *args, **kwargs):
|
||||
nonce = AppleNonce.objects.create(
|
||||
nonce=b64encode(token_bytes(32)).decode(), expires=now() + timedelta(minutes=5)
|
||||
)
|
||||
return JsonResponse(
|
||||
{
|
||||
"Nonce": nonce.nonce,
|
||||
}
|
||||
)
|
92
authentik/enterprise/providers/apple_psso/views/register.py
Normal file
92
authentik/enterprise/providers/apple_psso/views/register.py
Normal file
@ -0,0 +1,92 @@
|
||||
from django.shortcuts import get_object_or_404
|
||||
from rest_framework.authentication import BaseAuthentication
|
||||
from rest_framework.fields import CharField
|
||||
from rest_framework.request import Request
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from authentik.api.authentication import TokenAuthentication
|
||||
from authentik.core.api.utils import PassiveSerializer
|
||||
from authentik.core.models import User
|
||||
from authentik.enterprise.providers.apple_psso.models import (
|
||||
AppleDevice,
|
||||
AppleDeviceUser,
|
||||
ApplePlatformSSOProvider,
|
||||
)
|
||||
from authentik.lib.generators import generate_key
|
||||
|
||||
|
||||
class DeviceRegisterAuth(BaseAuthentication):
|
||||
def authenticate(self, request):
|
||||
# very temporary, lol
|
||||
return (User(), None)
|
||||
|
||||
|
||||
class RegisterDeviceView(APIView):
|
||||
|
||||
class DeviceRegistration(PassiveSerializer):
|
||||
|
||||
device_uuid = CharField()
|
||||
client_id = CharField()
|
||||
device_signing_key = CharField()
|
||||
device_encryption_key = CharField()
|
||||
sign_key_id = CharField()
|
||||
enc_key_id = CharField()
|
||||
|
||||
permission_classes = []
|
||||
pagination_class = None
|
||||
filter_backends = []
|
||||
serializer_class = DeviceRegistration
|
||||
authentication_classes = [DeviceRegisterAuth, TokenAuthentication]
|
||||
|
||||
def post(self, request: Request) -> Response:
|
||||
data = self.DeviceRegistration(data=request.data)
|
||||
data.is_valid(raise_exception=True)
|
||||
provider = get_object_or_404(
|
||||
ApplePlatformSSOProvider, client_id=data.validated_data["client_id"]
|
||||
)
|
||||
AppleDevice.objects.update_or_create(
|
||||
endpoint_uuid=data.validated_data["device_uuid"],
|
||||
defaults={
|
||||
"signing_key": data.validated_data["device_signing_key"],
|
||||
"encryption_key": data.validated_data["device_encryption_key"],
|
||||
"sign_key_id": data.validated_data["sign_key_id"],
|
||||
"enc_key_id": data.validated_data["enc_key_id"],
|
||||
"key_exchange_key": generate_key(),
|
||||
"provider": provider,
|
||||
},
|
||||
)
|
||||
return Response()
|
||||
|
||||
|
||||
class RegisterUserView(APIView):
|
||||
|
||||
class UserRegistration(PassiveSerializer):
|
||||
|
||||
device_uuid = CharField()
|
||||
user_secure_enclave_key = CharField()
|
||||
enclave_key_id = CharField()
|
||||
|
||||
permission_classes = []
|
||||
pagination_class = None
|
||||
filter_backends = []
|
||||
serializer_class = UserRegistration
|
||||
authentication_classes = [TokenAuthentication]
|
||||
|
||||
def post(self, request: Request) -> Response:
|
||||
data = self.UserRegistration(data=request.data)
|
||||
data.is_valid(raise_exception=True)
|
||||
device = get_object_or_404(AppleDevice, endpoint_uuid=data.validated_data["device_uuid"])
|
||||
AppleDeviceUser.objects.update_or_create(
|
||||
device=device,
|
||||
user=request.user,
|
||||
defaults={
|
||||
"secure_enclave_key": data.validated_data["user_secure_enclave_key"],
|
||||
"enclave_key_id": data.validated_data["enclave_key_id"],
|
||||
},
|
||||
)
|
||||
return Response(
|
||||
{
|
||||
"username": request.user.username,
|
||||
}
|
||||
)
|
@ -0,0 +1,16 @@
|
||||
from django.http import HttpRequest, HttpResponse, JsonResponse
|
||||
from django.views import View
|
||||
|
||||
|
||||
class AppleAppSiteAssociation(View):
|
||||
def get(self, request: HttpRequest) -> HttpResponse:
|
||||
return JsonResponse(
|
||||
{
|
||||
"authsrv": {
|
||||
"apps": [
|
||||
"232G855Y8N.io.goauthentik.endpoint",
|
||||
"232G855Y8N.io.goauthentik.endpoint.psso",
|
||||
]
|
||||
}
|
||||
}
|
||||
)
|
140
authentik/enterprise/providers/apple_psso/views/token.py
Normal file
140
authentik/enterprise/providers/apple_psso/views/token.py
Normal file
@ -0,0 +1,140 @@
|
||||
from datetime import timedelta
|
||||
|
||||
from django.http import Http404, HttpRequest, HttpResponse
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.utils.timezone import now
|
||||
from django.views import View
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
from jwt import PyJWT, decode
|
||||
from rest_framework.exceptions import ValidationError
|
||||
from structlog.stdlib import get_logger
|
||||
|
||||
from authentik.core.models import AuthenticatedSession, Session, User
|
||||
from authentik.core.sessions import SessionStore
|
||||
from authentik.enterprise.providers.apple_psso.http import JWEResponse
|
||||
from authentik.enterprise.providers.apple_psso.models import (
|
||||
AppleDevice,
|
||||
AppleDeviceUser,
|
||||
AppleNonce,
|
||||
ApplePlatformSSOProvider,
|
||||
)
|
||||
from authentik.events.models import Event, EventAction
|
||||
from authentik.events.signals import SESSION_LOGIN_EVENT
|
||||
from authentik.providers.oauth2.constants import TOKEN_TYPE
|
||||
from authentik.providers.oauth2.id_token import IDToken
|
||||
from authentik.providers.oauth2.models import RefreshToken
|
||||
from authentik.root.middleware import SessionMiddleware
|
||||
|
||||
LOGGER = get_logger()
|
||||
|
||||
|
||||
@method_decorator(csrf_exempt, name="dispatch")
|
||||
class TokenView(View):
|
||||
|
||||
device: AppleDevice
|
||||
provider: ApplePlatformSSOProvider
|
||||
|
||||
def post(self, request: HttpRequest) -> HttpResponse:
|
||||
version = request.POST.get("platform_sso_version")
|
||||
assertion = request.POST.get("assertion", request.POST.get("request"))
|
||||
if not assertion:
|
||||
return HttpResponse(status=400)
|
||||
|
||||
decode_unvalidated = PyJWT().decode_complete(assertion, options={"verify_signature": False})
|
||||
LOGGER.debug(decode_unvalidated["header"])
|
||||
expected_kid = decode_unvalidated["header"]["kid"]
|
||||
|
||||
self.device = AppleDevice.objects.filter(sign_key_id=expected_kid).first()
|
||||
if not self.device:
|
||||
raise Http404
|
||||
self.provider = self.device.provider
|
||||
|
||||
# Properly decode the JWT with the key from the device
|
||||
decoded = decode(
|
||||
assertion, self.device.signing_key, algorithms=["ES256"], options={"verify_aud": False}
|
||||
)
|
||||
LOGGER.debug(decoded)
|
||||
|
||||
LOGGER.debug("got device", device=self.device)
|
||||
|
||||
# Check that the nonce hasn't been used before
|
||||
nonce = AppleNonce.objects.filter(nonce=decoded["request_nonce"]).first()
|
||||
if not nonce:
|
||||
return HttpResponse(status=400)
|
||||
nonce.delete()
|
||||
|
||||
handler_func = (
|
||||
f"handle_v{version}_{decode_unvalidated["header"]["typ"]}".replace("-", "_")
|
||||
.replace("+", "_")
|
||||
.replace(".", "_")
|
||||
)
|
||||
handler = getattr(self, handler_func, None)
|
||||
if not handler:
|
||||
LOGGER.debug("Handler not found", handler=handler_func)
|
||||
return HttpResponse(status=400)
|
||||
LOGGER.debug("sending to handler", handler=handler_func)
|
||||
return handler(decoded)
|
||||
|
||||
def validate_device_user_response(self, assertion: str) -> tuple[AppleDeviceUser, dict] | None:
|
||||
"""Decode an embedded assertion and validate it by looking up the matching device user"""
|
||||
decode_unvalidated = PyJWT().decode_complete(assertion, options={"verify_signature": False})
|
||||
expected_kid = decode_unvalidated["header"]["kid"]
|
||||
|
||||
device_user = AppleDeviceUser.objects.filter(
|
||||
device=self.device, enclave_key_id=expected_kid
|
||||
).first()
|
||||
if not device_user:
|
||||
return None
|
||||
return device_user, decode(
|
||||
assertion,
|
||||
device_user.secure_enclave_key,
|
||||
audience="apple-platform-sso",
|
||||
algorithms=["ES256"],
|
||||
)
|
||||
|
||||
def create_auth_session(self, user: User):
|
||||
event = Event.new(EventAction.LOGIN).from_http(self.request, user=user)
|
||||
store = SessionStore()
|
||||
store[SESSION_LOGIN_EVENT] = event
|
||||
store.save()
|
||||
session = Session.objects.filter(session_key=store.session_key).first()
|
||||
AuthenticatedSession.objects.create(session=session, user=user)
|
||||
session = SessionMiddleware.encode_session(store.session_key, user)
|
||||
return session
|
||||
|
||||
def handle_v1_0_platformsso_login_request_jwt(self, decoded: dict):
|
||||
user = None
|
||||
if decoded["grant_type"] == "urn:ietf:params:oauth:grant-type:jwt-bearer":
|
||||
# Decode and validate inner assertion
|
||||
user, inner = self.validate_device_user_response(decoded["assertion"])
|
||||
if inner["nonce"] != decoded["nonce"]:
|
||||
LOGGER.warning("Mis-matched nonce to outer assertion")
|
||||
raise ValidationError("Invalid request")
|
||||
|
||||
refresh_token = RefreshToken(
|
||||
user=user.user,
|
||||
scope=decoded["scope"],
|
||||
expires=now() + timedelta(hours=8),
|
||||
provider=self.provider,
|
||||
auth_time=now(),
|
||||
session=None,
|
||||
)
|
||||
id_token = IDToken.new(
|
||||
self.provider,
|
||||
refresh_token,
|
||||
self.request,
|
||||
)
|
||||
id_token.nonce = decoded["nonce"]
|
||||
refresh_token.id_token = id_token
|
||||
refresh_token.save()
|
||||
return JWEResponse(
|
||||
{
|
||||
"refresh_token": refresh_token.token,
|
||||
"refresh_token_expires_in": int((refresh_token.expires - now()).total_seconds()),
|
||||
"id_token": refresh_token.id_token.to_jwt(self.provider),
|
||||
"token_type": TOKEN_TYPE,
|
||||
"session_key": self.create_auth_session(user.user),
|
||||
},
|
||||
device=self.device,
|
||||
apv=decoded["jwe_crypto"]["apv"],
|
||||
)
|
@ -1,10 +1,8 @@
|
||||
from hashlib import sha256
|
||||
|
||||
from django.contrib.auth.signals import user_logged_out
|
||||
from django.db.models import Model
|
||||
from django.db.models.signals import post_delete, post_save, pre_delete
|
||||
from django.dispatch import receiver
|
||||
from django.http.request import HttpRequest
|
||||
from guardian.shortcuts import assign_perm
|
||||
|
||||
from authentik.core.models import (
|
||||
@ -62,31 +60,6 @@ def ssf_providers_post_save(sender: type[Model], instance: SSFProvider, created:
|
||||
instance.save()
|
||||
|
||||
|
||||
@receiver(user_logged_out)
|
||||
def ssf_user_logged_out_session_revoked(sender, request: HttpRequest, user: User, **_):
|
||||
"""Session revoked trigger (user logged out)"""
|
||||
if not request.session or not request.session.session_key or not user:
|
||||
return
|
||||
send_ssf_event(
|
||||
EventTypes.CAEP_SESSION_REVOKED,
|
||||
{
|
||||
"initiating_entity": "user",
|
||||
},
|
||||
sub_id={
|
||||
"format": "complex",
|
||||
"session": {
|
||||
"format": "opaque",
|
||||
"id": sha256(request.session.session_key.encode("ascii")).hexdigest(),
|
||||
},
|
||||
"user": {
|
||||
"format": "email",
|
||||
"email": user.email,
|
||||
},
|
||||
},
|
||||
request=request,
|
||||
)
|
||||
|
||||
|
||||
@receiver(pre_delete, sender=AuthenticatedSession)
|
||||
def ssf_user_session_delete_session_revoked(sender, instance: AuthenticatedSession, **_):
|
||||
"""Session revoked trigger (users' session has been deleted)
|
||||
|
@ -15,6 +15,7 @@ CELERY_BEAT_SCHEDULE = {
|
||||
TENANT_APPS = [
|
||||
"authentik.enterprise.audit",
|
||||
"authentik.enterprise.policies.unique_password",
|
||||
"authentik.enterprise.providers.apple_psso",
|
||||
"authentik.enterprise.providers.google_workspace",
|
||||
"authentik.enterprise.providers.microsoft_entra",
|
||||
"authentik.enterprise.providers.ssf",
|
||||
|
@ -97,6 +97,7 @@ class SourceStageFinal(StageView):
|
||||
token: FlowToken = self.request.session.get(SESSION_KEY_OVERRIDE_FLOW_TOKEN)
|
||||
self.logger.info("Replacing source flow with overridden flow", flow=token.flow.slug)
|
||||
plan = token.plan
|
||||
plan.context.update(self.executor.plan.context)
|
||||
plan.context[PLAN_CONTEXT_IS_RESTORED] = token
|
||||
response = plan.to_redirect(self.request, token.flow)
|
||||
token.delete()
|
||||
|
@ -90,14 +90,17 @@ class TestSourceStage(FlowTestCase):
|
||||
plan: FlowPlan = session[SESSION_KEY_PLAN]
|
||||
plan.insert_stage(in_memory_stage(SourceStageFinal), index=0)
|
||||
plan.context[PLAN_CONTEXT_IS_RESTORED] = flow_token
|
||||
plan.context["foo"] = "bar"
|
||||
session[SESSION_KEY_PLAN] = plan
|
||||
session.save()
|
||||
|
||||
# Pretend we've just returned from the source
|
||||
response = self.client.get(
|
||||
reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}), follow=True
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertStageRedirects(
|
||||
response, reverse("authentik_core:if-flow", kwargs={"flow_slug": flow.slug})
|
||||
)
|
||||
with self.assertFlowFinishes() as ff:
|
||||
response = self.client.get(
|
||||
reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}), follow=True
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertStageRedirects(
|
||||
response, reverse("authentik_core:if-flow", kwargs={"flow_slug": flow.slug})
|
||||
)
|
||||
self.assertEqual(ff().context["foo"], "bar")
|
||||
|
@ -19,8 +19,8 @@ from authentik.blueprints.v1.importer import excluded_models
|
||||
from authentik.core.models import Group, User
|
||||
from authentik.events.models import Event, EventAction, Notification
|
||||
from authentik.events.utils import model_to_dict
|
||||
from authentik.lib.sentry import before_send
|
||||
from authentik.lib.utils.errors import exception_to_dict
|
||||
from authentik.lib.sentry import should_ignore_exception
|
||||
from authentik.lib.utils.errors import exception_to_string
|
||||
from authentik.stages.authenticator_static.models import StaticToken
|
||||
|
||||
IGNORED_MODELS = tuple(
|
||||
@ -170,16 +170,14 @@ class AuditMiddleware:
|
||||
thread = EventNewThread(
|
||||
EventAction.SUSPICIOUS_REQUEST,
|
||||
request,
|
||||
message=str(exception),
|
||||
exception=exception_to_dict(exception),
|
||||
message=exception_to_string(exception),
|
||||
)
|
||||
thread.run()
|
||||
elif before_send({}, {"exc_info": (None, exception, None)}) is not None:
|
||||
elif not should_ignore_exception(exception):
|
||||
thread = EventNewThread(
|
||||
EventAction.SYSTEM_EXCEPTION,
|
||||
request,
|
||||
message=str(exception),
|
||||
exception=exception_to_dict(exception),
|
||||
message=exception_to_string(exception),
|
||||
)
|
||||
thread.run()
|
||||
|
||||
|
@ -38,7 +38,6 @@ from authentik.events.utils import (
|
||||
)
|
||||
from authentik.lib.models import DomainlessURLValidator, SerializerModel
|
||||
from authentik.lib.sentry import SentryIgnoredException
|
||||
from authentik.lib.utils.errors import exception_to_dict
|
||||
from authentik.lib.utils.http import get_http_session
|
||||
from authentik.lib.utils.time import timedelta_from_string
|
||||
from authentik.policies.models import PolicyBindingModel
|
||||
@ -164,12 +163,6 @@ class Event(SerializerModel, ExpiringModel):
|
||||
event = Event(action=action, app=app, context=cleaned_kwargs)
|
||||
return event
|
||||
|
||||
def with_exception(self, exc: Exception) -> "Event":
|
||||
"""Add data from 'exc' to the event in a database-saveable format"""
|
||||
self.context.setdefault("message", str(exc))
|
||||
self.context["exception"] = exception_to_dict(exc)
|
||||
return self
|
||||
|
||||
def set_user(self, user: User) -> "Event":
|
||||
"""Set `.user` based on user, ensuring the correct attributes are copied.
|
||||
This should only be used when self.from_http is *not* used."""
|
||||
@ -200,17 +193,32 @@ class Event(SerializerModel, ExpiringModel):
|
||||
brand: Brand = request.brand
|
||||
self.brand = sanitize_dict(model_to_dict(brand))
|
||||
if hasattr(request, "user"):
|
||||
original_user = None
|
||||
if hasattr(request, "session"):
|
||||
original_user = request.session.get(SESSION_KEY_IMPERSONATE_ORIGINAL_USER, None)
|
||||
self.user = get_user(request.user, original_user)
|
||||
self.user = get_user(request.user)
|
||||
if user:
|
||||
self.user = get_user(user)
|
||||
# Check if we're currently impersonating, and add that user
|
||||
if hasattr(request, "session"):
|
||||
from authentik.flows.views.executor import SESSION_KEY_PLAN
|
||||
|
||||
# Check if we're currently impersonating, and add that user
|
||||
if SESSION_KEY_IMPERSONATE_ORIGINAL_USER in request.session:
|
||||
self.user = get_user(request.session[SESSION_KEY_IMPERSONATE_ORIGINAL_USER])
|
||||
self.user["on_behalf_of"] = get_user(request.session[SESSION_KEY_IMPERSONATE_USER])
|
||||
# Special case for events that happen during a flow, the user might not be authenticated
|
||||
# yet but is a pending user instead
|
||||
if SESSION_KEY_PLAN in request.session:
|
||||
from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlan
|
||||
|
||||
plan: FlowPlan = request.session[SESSION_KEY_PLAN]
|
||||
pending_user = plan.context.get(PLAN_CONTEXT_PENDING_USER, None)
|
||||
# Only save `authenticated_as` if there's a different pending user in the flow
|
||||
# than the user that is authenticated
|
||||
if pending_user and (
|
||||
(pending_user.pk and pending_user.pk != self.user.get("pk"))
|
||||
or (not pending_user.pk)
|
||||
):
|
||||
orig_user = self.user.copy()
|
||||
|
||||
self.user = {"authenticated_as": orig_user, **get_user(pending_user)}
|
||||
# User 255.255.255.255 as fallback if IP cannot be determined
|
||||
self.client_ip = ClientIPMiddleware.get_client_ip(request)
|
||||
# Enrich event data
|
||||
|
@ -127,8 +127,8 @@ class SystemTask(TenantTask):
|
||||
)
|
||||
Event.new(
|
||||
EventAction.SYSTEM_TASK_EXCEPTION,
|
||||
message=f"Task {self.__name__} encountered an error",
|
||||
).with_exception(exc).save()
|
||||
message=f"Task {self.__name__} encountered an error: {exception_to_string(exc)}",
|
||||
).save()
|
||||
|
||||
def run(self, *args, **kwargs):
|
||||
raise NotImplementedError
|
||||
|
@ -2,7 +2,9 @@
|
||||
|
||||
from django.test import TestCase
|
||||
|
||||
from authentik.events.context_processors.base import get_context_processors
|
||||
from authentik.events.context_processors.geoip import GeoIPContextProcessor
|
||||
from authentik.events.models import Event, EventAction
|
||||
|
||||
|
||||
class TestGeoIP(TestCase):
|
||||
@ -13,8 +15,7 @@ class TestGeoIP(TestCase):
|
||||
|
||||
def test_simple(self):
|
||||
"""Test simple city wrapper"""
|
||||
# IPs from
|
||||
# https://github.com/maxmind/MaxMind-DB/blob/main/source-data/GeoLite2-City-Test.json
|
||||
# IPs from https://github.com/maxmind/MaxMind-DB/blob/main/source-data/GeoLite2-City-Test.json
|
||||
self.assertEqual(
|
||||
self.reader.city_dict("2.125.160.216"),
|
||||
{
|
||||
@ -25,3 +26,12 @@ class TestGeoIP(TestCase):
|
||||
"long": -1.25,
|
||||
},
|
||||
)
|
||||
|
||||
def test_special_chars(self):
|
||||
"""Test city name with special characters"""
|
||||
# IPs from https://github.com/maxmind/MaxMind-DB/blob/main/source-data/GeoLite2-City-Test.json
|
||||
event = Event.new(EventAction.LOGIN)
|
||||
event.client_ip = "89.160.20.112"
|
||||
for processor in get_context_processors():
|
||||
processor.enrich_event(event)
|
||||
event.save()
|
||||
|
@ -8,9 +8,11 @@ from django.views.debug import SafeExceptionReporterFilter
|
||||
from guardian.shortcuts import get_anonymous_user
|
||||
|
||||
from authentik.brands.models import Brand
|
||||
from authentik.core.models import Group
|
||||
from authentik.core.models import Group, User
|
||||
from authentik.core.tests.utils import create_test_user
|
||||
from authentik.events.models import Event
|
||||
from authentik.flows.views.executor import QS_QUERY
|
||||
from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlan
|
||||
from authentik.flows.views.executor import QS_QUERY, SESSION_KEY_PLAN
|
||||
from authentik.lib.generators import generate_id
|
||||
from authentik.policies.dummy.models import DummyPolicy
|
||||
|
||||
@ -116,3 +118,92 @@ class TestEvents(TestCase):
|
||||
"pk": brand.pk.hex,
|
||||
},
|
||||
)
|
||||
|
||||
def test_from_http_flow_pending_user(self):
|
||||
"""Test request from flow request with a pending user"""
|
||||
user = create_test_user()
|
||||
|
||||
session = self.client.session
|
||||
plan = FlowPlan(generate_id())
|
||||
plan.context[PLAN_CONTEXT_PENDING_USER] = user
|
||||
session[SESSION_KEY_PLAN] = plan
|
||||
session.save()
|
||||
|
||||
request = self.factory.get("/")
|
||||
request.session = session
|
||||
request.user = user
|
||||
|
||||
event = Event.new("unittest").from_http(request)
|
||||
self.assertEqual(
|
||||
event.user,
|
||||
{
|
||||
"email": user.email,
|
||||
"pk": user.pk,
|
||||
"username": user.username,
|
||||
},
|
||||
)
|
||||
|
||||
def test_from_http_flow_pending_user_anon(self):
|
||||
"""Test request from flow request with a pending user"""
|
||||
user = create_test_user()
|
||||
anon = get_anonymous_user()
|
||||
|
||||
session = self.client.session
|
||||
plan = FlowPlan(generate_id())
|
||||
plan.context[PLAN_CONTEXT_PENDING_USER] = user
|
||||
session[SESSION_KEY_PLAN] = plan
|
||||
session.save()
|
||||
|
||||
request = self.factory.get("/")
|
||||
request.session = session
|
||||
request.user = anon
|
||||
|
||||
event = Event.new("unittest").from_http(request)
|
||||
self.assertEqual(
|
||||
event.user,
|
||||
{
|
||||
"authenticated_as": {
|
||||
"pk": anon.pk,
|
||||
"is_anonymous": True,
|
||||
"username": "AnonymousUser",
|
||||
"email": "",
|
||||
},
|
||||
"email": user.email,
|
||||
"pk": user.pk,
|
||||
"username": user.username,
|
||||
},
|
||||
)
|
||||
|
||||
def test_from_http_flow_pending_user_fake(self):
|
||||
"""Test request from flow request with a pending user"""
|
||||
user = User(
|
||||
username=generate_id(),
|
||||
email=generate_id(),
|
||||
)
|
||||
anon = get_anonymous_user()
|
||||
|
||||
session = self.client.session
|
||||
plan = FlowPlan(generate_id())
|
||||
plan.context[PLAN_CONTEXT_PENDING_USER] = user
|
||||
session[SESSION_KEY_PLAN] = plan
|
||||
session.save()
|
||||
|
||||
request = self.factory.get("/")
|
||||
request.session = session
|
||||
request.user = anon
|
||||
|
||||
event = Event.new("unittest").from_http(request)
|
||||
self.assertEqual(
|
||||
event.user,
|
||||
{
|
||||
"authenticated_as": {
|
||||
"pk": anon.pk,
|
||||
"is_anonymous": True,
|
||||
"username": "AnonymousUser",
|
||||
"email": "",
|
||||
},
|
||||
"email": user.email,
|
||||
"pk": user.pk,
|
||||
"username": user.username,
|
||||
},
|
||||
)
|
||||
|
@ -74,8 +74,8 @@ def model_to_dict(model: Model) -> dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def get_user(user: User | AnonymousUser, original_user: User | None = None) -> dict[str, Any]:
|
||||
"""Convert user object to dictionary, optionally including the original user"""
|
||||
def get_user(user: User | AnonymousUser) -> dict[str, Any]:
|
||||
"""Convert user object to dictionary"""
|
||||
if isinstance(user, AnonymousUser):
|
||||
try:
|
||||
user = get_anonymous_user()
|
||||
@ -88,10 +88,6 @@ def get_user(user: User | AnonymousUser, original_user: User | None = None) -> d
|
||||
}
|
||||
if user.username == settings.ANONYMOUS_USER_NAME:
|
||||
user_data["is_anonymous"] = True
|
||||
if original_user:
|
||||
original_data = get_user(original_user)
|
||||
original_data["on_behalf_of"] = user_data
|
||||
return original_data
|
||||
return user_data
|
||||
|
||||
|
||||
|
@ -4,8 +4,10 @@ from unittest.mock import MagicMock, PropertyMock, patch
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from django.http import HttpRequest, HttpResponse
|
||||
from django.test import override_settings
|
||||
from django.test.client import RequestFactory
|
||||
from django.urls import reverse
|
||||
from rest_framework.exceptions import ParseError
|
||||
|
||||
from authentik.core.models import Group, User
|
||||
from authentik.core.tests.utils import create_test_flow, create_test_user
|
||||
@ -648,3 +650,25 @@ class TestFlowExecutor(FlowTestCase):
|
||||
self.assertStageResponse(response, flow, component="ak-stage-identification")
|
||||
response = self.client.post(exec_url, {"uid_field": user_other.username}, follow=True)
|
||||
self.assertStageResponse(response, flow, component="ak-stage-access-denied")
|
||||
|
||||
@patch(
|
||||
"authentik.flows.views.executor.to_stage_response",
|
||||
TO_STAGE_RESPONSE_MOCK,
|
||||
)
|
||||
def test_invalid_json(self):
|
||||
"""Test invalid JSON body"""
|
||||
flow = create_test_flow()
|
||||
FlowStageBinding.objects.create(
|
||||
target=flow, stage=DummyStage.objects.create(name=generate_id()), order=0
|
||||
)
|
||||
url = reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug})
|
||||
|
||||
with override_settings(TEST=False, DEBUG=False):
|
||||
self.client.logout()
|
||||
response = self.client.post(url, data="{", content_type="application/json")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
with self.assertRaises(ParseError):
|
||||
self.client.logout()
|
||||
response = self.client.post(url, data="{", content_type="application/json")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
@ -55,7 +55,8 @@ from authentik.flows.planner import (
|
||||
FlowPlanner,
|
||||
)
|
||||
from authentik.flows.stage import AccessDeniedStage, StageView
|
||||
from authentik.lib.sentry import SentryIgnoredException
|
||||
from authentik.lib.sentry import SentryIgnoredException, should_ignore_exception
|
||||
from authentik.lib.utils.errors import exception_to_string
|
||||
from authentik.lib.utils.reflection import all_subclasses, class_to_path
|
||||
from authentik.lib.utils.urls import is_url_absolute, redirect_with_qs
|
||||
from authentik.policies.engine import PolicyEngine
|
||||
@ -233,12 +234,13 @@ class FlowExecutorView(APIView):
|
||||
"""Handle exception in stage execution"""
|
||||
if settings.DEBUG or settings.TEST:
|
||||
raise exc
|
||||
capture_exception(exc)
|
||||
self._logger.warning(exc)
|
||||
Event.new(
|
||||
action=EventAction.SYSTEM_EXCEPTION,
|
||||
message="System exception during flow execution.",
|
||||
).with_exception(exc).from_http(self.request)
|
||||
if not should_ignore_exception(exc):
|
||||
capture_exception(exc)
|
||||
Event.new(
|
||||
action=EventAction.SYSTEM_EXCEPTION,
|
||||
message=exception_to_string(exc),
|
||||
).from_http(self.request)
|
||||
challenge = FlowErrorChallenge(self.request, exc)
|
||||
challenge.is_valid(raise_exception=True)
|
||||
return to_stage_response(self.request, HttpChallengeResponse(challenge))
|
||||
|
@ -14,6 +14,7 @@ from django_redis.exceptions import ConnectionInterrupted
|
||||
from docker.errors import DockerException
|
||||
from h11 import LocalProtocolError
|
||||
from ldap3.core.exceptions import LDAPException
|
||||
from psycopg.errors import Error
|
||||
from redis.exceptions import ConnectionError as RedisConnectionError
|
||||
from redis.exceptions import RedisError, ResponseError
|
||||
from rest_framework.exceptions import APIException
|
||||
@ -44,6 +45,49 @@ class SentryIgnoredException(Exception):
|
||||
"""Base Class for all errors that are suppressed, and not sent to sentry."""
|
||||
|
||||
|
||||
ignored_classes = (
|
||||
# Inbuilt types
|
||||
KeyboardInterrupt,
|
||||
ConnectionResetError,
|
||||
OSError,
|
||||
PermissionError,
|
||||
# Django Errors
|
||||
Error,
|
||||
ImproperlyConfigured,
|
||||
DatabaseError,
|
||||
OperationalError,
|
||||
InternalError,
|
||||
ProgrammingError,
|
||||
SuspiciousOperation,
|
||||
ValidationError,
|
||||
# Redis errors
|
||||
RedisConnectionError,
|
||||
ConnectionInterrupted,
|
||||
RedisError,
|
||||
ResponseError,
|
||||
# websocket errors
|
||||
ChannelFull,
|
||||
WebSocketException,
|
||||
LocalProtocolError,
|
||||
# rest_framework error
|
||||
APIException,
|
||||
# celery errors
|
||||
WorkerLostError,
|
||||
CeleryError,
|
||||
SoftTimeLimitExceeded,
|
||||
# custom baseclass
|
||||
SentryIgnoredException,
|
||||
# ldap errors
|
||||
LDAPException,
|
||||
# Docker errors
|
||||
DockerException,
|
||||
# End-user errors
|
||||
Http404,
|
||||
# AsyncIO
|
||||
CancelledError,
|
||||
)
|
||||
|
||||
|
||||
class SentryTransport(HttpTransport):
|
||||
"""Custom sentry transport with custom user-agent"""
|
||||
|
||||
@ -101,56 +145,17 @@ def traces_sampler(sampling_context: dict) -> float:
|
||||
return float(CONFIG.get("error_reporting.sample_rate", 0.1))
|
||||
|
||||
|
||||
def should_ignore_exception(exc: Exception) -> bool:
|
||||
"""Check if an exception should be dropped"""
|
||||
return isinstance(exc, ignored_classes)
|
||||
|
||||
|
||||
def before_send(event: dict, hint: dict) -> dict | None:
|
||||
"""Check if error is database error, and ignore if so"""
|
||||
|
||||
from psycopg.errors import Error
|
||||
|
||||
ignored_classes = (
|
||||
# Inbuilt types
|
||||
KeyboardInterrupt,
|
||||
ConnectionResetError,
|
||||
OSError,
|
||||
PermissionError,
|
||||
# Django Errors
|
||||
Error,
|
||||
ImproperlyConfigured,
|
||||
DatabaseError,
|
||||
OperationalError,
|
||||
InternalError,
|
||||
ProgrammingError,
|
||||
SuspiciousOperation,
|
||||
ValidationError,
|
||||
# Redis errors
|
||||
RedisConnectionError,
|
||||
ConnectionInterrupted,
|
||||
RedisError,
|
||||
ResponseError,
|
||||
# websocket errors
|
||||
ChannelFull,
|
||||
WebSocketException,
|
||||
LocalProtocolError,
|
||||
# rest_framework error
|
||||
APIException,
|
||||
# celery errors
|
||||
WorkerLostError,
|
||||
CeleryError,
|
||||
SoftTimeLimitExceeded,
|
||||
# custom baseclass
|
||||
SentryIgnoredException,
|
||||
# ldap errors
|
||||
LDAPException,
|
||||
# Docker errors
|
||||
DockerException,
|
||||
# End-user errors
|
||||
Http404,
|
||||
# AsyncIO
|
||||
CancelledError,
|
||||
)
|
||||
exc_value = None
|
||||
if "exc_info" in hint:
|
||||
_, exc_value, _ = hint["exc_info"]
|
||||
if isinstance(exc_value, ignored_classes):
|
||||
if should_ignore_exception(exc_value):
|
||||
LOGGER.debug("dropping exception", exc=exc_value)
|
||||
return None
|
||||
if "logger" in event:
|
||||
|
@ -14,6 +14,7 @@ from authentik.events.models import Event, EventAction
|
||||
from authentik.lib.expression.exceptions import ControlFlowException
|
||||
from authentik.lib.sync.mapper import PropertyMappingManager
|
||||
from authentik.lib.sync.outgoing.exceptions import NotFoundSyncException, StopSync
|
||||
from authentik.lib.utils.errors import exception_to_string
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from django.db.models import Model
|
||||
@ -105,9 +106,9 @@ class BaseOutgoingSyncClient[
|
||||
# Value error can be raised when assigning invalid data to an attribute
|
||||
Event.new(
|
||||
EventAction.CONFIGURATION_ERROR,
|
||||
message="Failed to evaluate property-mapping",
|
||||
message=f"Failed to evaluate property-mapping {exception_to_string(exc)}",
|
||||
mapping=exc.mapping,
|
||||
).with_exception(exc).save()
|
||||
).save()
|
||||
raise StopSync(exc, obj, exc.mapping) from exc
|
||||
if not raw_final_object:
|
||||
raise StopSync(ValueError("No mappings configured"), obj)
|
||||
|
@ -2,7 +2,7 @@
|
||||
|
||||
from django.test import TestCase
|
||||
|
||||
from authentik.lib.sentry import SentryIgnoredException, before_send
|
||||
from authentik.lib.sentry import SentryIgnoredException, should_ignore_exception
|
||||
|
||||
|
||||
class TestSentry(TestCase):
|
||||
@ -10,8 +10,8 @@ class TestSentry(TestCase):
|
||||
|
||||
def test_error_not_sent(self):
|
||||
"""Test SentryIgnoredError not sent"""
|
||||
self.assertIsNone(before_send({}, {"exc_info": (0, SentryIgnoredException(), 0)}))
|
||||
self.assertTrue(should_ignore_exception(SentryIgnoredException()))
|
||||
|
||||
def test_error_sent(self):
|
||||
"""Test error sent"""
|
||||
self.assertEqual({}, before_send({}, {"exc_info": (0, ValueError(), 0)}))
|
||||
self.assertFalse(should_ignore_exception(ValueError()))
|
||||
|
@ -2,8 +2,6 @@
|
||||
|
||||
from traceback import extract_tb
|
||||
|
||||
from structlog.tracebacks import ExceptionDictTransformer
|
||||
|
||||
from authentik.lib.utils.reflection import class_to_path
|
||||
|
||||
TRACEBACK_HEADER = "Traceback (most recent call last):"
|
||||
@ -19,8 +17,3 @@ def exception_to_string(exc: Exception) -> str:
|
||||
f"{class_to_path(exc.__class__)}: {str(exc)}",
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def exception_to_dict(exc: Exception) -> dict:
|
||||
"""Format exception as a dictionary"""
|
||||
return ExceptionDictTransformer()((type(exc), exc, exc.__traceback__))
|
||||
|
@ -35,6 +35,7 @@ from authentik.events.models import Event, EventAction
|
||||
from authentik.lib.config import CONFIG
|
||||
from authentik.lib.models import InheritanceForeignKey, SerializerModel
|
||||
from authentik.lib.sentry import SentryIgnoredException
|
||||
from authentik.lib.utils.errors import exception_to_string
|
||||
from authentik.outposts.controllers.k8s.utils import get_namespace
|
||||
|
||||
OUR_VERSION = parse(__version__)
|
||||
@ -325,8 +326,9 @@ class Outpost(SerializerModel, ManagedModel):
|
||||
"While setting the permissions for the service-account, a "
|
||||
"permission was not found: Check "
|
||||
"https://goauthentik.io/docs/troubleshooting/missing_permission"
|
||||
),
|
||||
).with_exception(exc).set_user(user).save()
|
||||
)
|
||||
+ exception_to_string(exc),
|
||||
).set_user(user).save()
|
||||
else:
|
||||
app_label, perm = model_or_perm.split(".")
|
||||
permission = Permission.objects.filter(
|
||||
|
@ -1,15 +1,13 @@
|
||||
"""authentik outpost signals"""
|
||||
|
||||
from django.contrib.auth.signals import user_logged_out
|
||||
from django.core.cache import cache
|
||||
from django.db.models import Model
|
||||
from django.db.models.signals import m2m_changed, post_save, pre_delete, pre_save
|
||||
from django.dispatch import receiver
|
||||
from django.http import HttpRequest
|
||||
from structlog.stdlib import get_logger
|
||||
|
||||
from authentik.brands.models import Brand
|
||||
from authentik.core.models import AuthenticatedSession, Provider, User
|
||||
from authentik.core.models import AuthenticatedSession, Provider
|
||||
from authentik.crypto.models import CertificateKeyPair
|
||||
from authentik.lib.utils.reflection import class_to_path
|
||||
from authentik.outposts.models import Outpost, OutpostServiceConnection
|
||||
@ -82,14 +80,6 @@ def pre_delete_cleanup(sender, instance: Outpost, **_):
|
||||
outpost_controller.delay(instance.pk.hex, action="down", from_cache=True)
|
||||
|
||||
|
||||
@receiver(user_logged_out)
|
||||
def logout_revoke_direct(sender: type[User], request: HttpRequest, **_):
|
||||
"""Catch logout by direct logout and forward to providers"""
|
||||
if not request.session or not request.session.session_key:
|
||||
return
|
||||
outpost_session_end.delay(request.session.session_key)
|
||||
|
||||
|
||||
@receiver(pre_delete, sender=AuthenticatedSession)
|
||||
def logout_revoke(sender: type[AuthenticatedSession], instance: AuthenticatedSession, **_):
|
||||
"""Catch logout by expiring sessions being deleted"""
|
||||
|
@ -10,7 +10,7 @@ from structlog.stdlib import get_logger
|
||||
|
||||
from authentik.events.models import Event, EventAction
|
||||
from authentik.lib.config import CONFIG
|
||||
from authentik.lib.utils.errors import exception_to_dict
|
||||
from authentik.lib.utils.errors import exception_to_string
|
||||
from authentik.lib.utils.reflection import class_to_path
|
||||
from authentik.policies.apps import HIST_POLICIES_EXECUTION_TIME
|
||||
from authentik.policies.exceptions import PolicyException
|
||||
@ -95,13 +95,10 @@ class PolicyProcess(PROCESS_CLASS):
|
||||
except PolicyException as exc:
|
||||
# Either use passed original exception or whatever we have
|
||||
src_exc = exc.src_exc if exc.src_exc else exc
|
||||
error_string = exception_to_string(src_exc)
|
||||
# Create policy exception event, only when we're not debugging
|
||||
if not self.request.debug:
|
||||
self.create_event(
|
||||
EventAction.POLICY_EXCEPTION,
|
||||
message="Policy failed to execute",
|
||||
exception=exception_to_dict(src_exc),
|
||||
)
|
||||
self.create_event(EventAction.POLICY_EXCEPTION, message=error_string)
|
||||
LOGGER.debug("P_ENG(proc): error, using failure result", exc=src_exc)
|
||||
policy_result = PolicyResult(self.binding.failure_result, str(src_exc))
|
||||
policy_result.source_binding = self.binding
|
||||
@ -146,5 +143,5 @@ class PolicyProcess(PROCESS_CLASS):
|
||||
try:
|
||||
self.connection.send(self.profiling_wrapper())
|
||||
except Exception as exc:
|
||||
LOGGER.warning("Policy failed to run", exc=exc)
|
||||
LOGGER.warning("Policy failed to run", exc=exception_to_string(exc))
|
||||
self.connection.send(PolicyResult(False, str(exc)))
|
||||
|
@ -237,4 +237,4 @@ class TestPolicyProcess(TestCase):
|
||||
self.assertEqual(len(events), 1)
|
||||
event = events.first()
|
||||
self.assertEqual(event.user["username"], self.user.username)
|
||||
self.assertIn("Policy failed to execute", event.context["message"])
|
||||
self.assertIn("division by zero", event.context["message"])
|
||||
|
@ -1,23 +1,10 @@
|
||||
from django.contrib.auth.signals import user_logged_out
|
||||
from django.db.models.signals import post_save, pre_delete
|
||||
from django.dispatch import receiver
|
||||
from django.http import HttpRequest
|
||||
|
||||
from authentik.core.models import AuthenticatedSession, User
|
||||
from authentik.providers.oauth2.models import AccessToken, DeviceToken, RefreshToken
|
||||
|
||||
|
||||
@receiver(user_logged_out)
|
||||
def user_logged_out_oauth_tokens_removal(sender, request: HttpRequest, user: User, **_):
|
||||
"""Revoke tokens upon user logout"""
|
||||
if not request.session or not request.session.session_key:
|
||||
return
|
||||
AccessToken.objects.filter(
|
||||
user=user,
|
||||
session__session__session_key=request.session.session_key,
|
||||
).delete()
|
||||
|
||||
|
||||
@receiver(pre_delete, sender=AuthenticatedSession)
|
||||
def user_session_deleted_oauth_tokens_removal(sender, instance: AuthenticatedSession, **_):
|
||||
"""Revoke tokens upon user logout"""
|
||||
|
@ -555,6 +555,8 @@ class TokenView(View):
|
||||
|
||||
provider: OAuth2Provider | None = None
|
||||
params: TokenParams | None = None
|
||||
params_class = TokenParams
|
||||
provider_class = OAuth2Provider
|
||||
|
||||
def dispatch(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
|
||||
response = super().dispatch(request, *args, **kwargs)
|
||||
@ -574,12 +576,14 @@ class TokenView(View):
|
||||
op="authentik.providers.oauth2.post.parse",
|
||||
):
|
||||
client_id, client_secret = extract_client_auth(request)
|
||||
self.provider = OAuth2Provider.objects.filter(client_id=client_id).first()
|
||||
self.provider = self.provider_class.objects.filter(client_id=client_id).first()
|
||||
if not self.provider:
|
||||
LOGGER.warning("OAuth2Provider does not exist", client_id=client_id)
|
||||
raise TokenError("invalid_client")
|
||||
CTX_AUTH_VIA.set("oauth_client_secret")
|
||||
self.params = TokenParams.parse(request, self.provider, client_id, client_secret)
|
||||
self.params = self.params_class.parse(
|
||||
request, self.provider, client_id, client_secret
|
||||
)
|
||||
|
||||
with start_span(
|
||||
op="authentik.providers.oauth2.post.response",
|
||||
|
@ -66,7 +66,10 @@ class RACClientConsumer(AsyncWebsocketConsumer):
|
||||
def init_outpost_connection(self):
|
||||
"""Initialize guac connection settings"""
|
||||
self.token = (
|
||||
ConnectionToken.filter_not_expired(token=self.scope["url_route"]["kwargs"]["token"])
|
||||
ConnectionToken.filter_not_expired(
|
||||
token=self.scope["url_route"]["kwargs"]["token"],
|
||||
session__session__session_key=self.scope["session"].session_key,
|
||||
)
|
||||
.select_related("endpoint", "provider", "session", "session__user")
|
||||
.first()
|
||||
)
|
||||
|
@ -2,13 +2,11 @@
|
||||
|
||||
from asgiref.sync import async_to_sync
|
||||
from channels.layers import get_channel_layer
|
||||
from django.contrib.auth.signals import user_logged_out
|
||||
from django.core.cache import cache
|
||||
from django.db.models.signals import post_delete, post_save, pre_delete
|
||||
from django.dispatch import receiver
|
||||
from django.http import HttpRequest
|
||||
|
||||
from authentik.core.models import AuthenticatedSession, User
|
||||
from authentik.core.models import AuthenticatedSession
|
||||
from authentik.providers.rac.api.endpoints import user_endpoint_cache_key
|
||||
from authentik.providers.rac.consumer_client import (
|
||||
RAC_CLIENT_GROUP_SESSION,
|
||||
@ -17,21 +15,6 @@ from authentik.providers.rac.consumer_client import (
|
||||
from authentik.providers.rac.models import ConnectionToken, Endpoint
|
||||
|
||||
|
||||
@receiver(user_logged_out)
|
||||
def user_logged_out_session(sender, request: HttpRequest, user: User, **_):
|
||||
"""Disconnect any open RAC connections"""
|
||||
if not request.session or not request.session.session_key:
|
||||
return
|
||||
layer = get_channel_layer()
|
||||
async_to_sync(layer.group_send)(
|
||||
RAC_CLIENT_GROUP_SESSION
|
||||
% {
|
||||
"session": request.session.session_key,
|
||||
},
|
||||
{"type": "event.disconnect", "reason": "session_logout"},
|
||||
)
|
||||
|
||||
|
||||
@receiver(pre_delete, sender=AuthenticatedSession)
|
||||
def user_session_deleted(sender, instance: AuthenticatedSession, **_):
|
||||
layer = get_channel_layer()
|
||||
|
@ -87,3 +87,22 @@ class TestRACViews(APITestCase):
|
||||
)
|
||||
body = loads(flow_response.content)
|
||||
self.assertEqual(body["component"], "ak-stage-access-denied")
|
||||
|
||||
def test_different_session(self):
|
||||
"""Test request"""
|
||||
self.client.force_login(self.user)
|
||||
response = self.client.get(
|
||||
reverse(
|
||||
"authentik_providers_rac:start",
|
||||
kwargs={"app": self.app.slug, "endpoint": str(self.endpoint.pk)},
|
||||
)
|
||||
)
|
||||
self.assertEqual(response.status_code, 302)
|
||||
flow_response = self.client.get(
|
||||
reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug})
|
||||
)
|
||||
body = loads(flow_response.content)
|
||||
next_url = body["to"]
|
||||
self.client.logout()
|
||||
final_response = self.client.get(next_url)
|
||||
self.assertEqual(final_response.url, reverse("authentik_core:if-user"))
|
||||
|
@ -68,7 +68,10 @@ class RACInterface(InterfaceView):
|
||||
|
||||
def dispatch(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
|
||||
# Early sanity check to ensure token still exists
|
||||
token = ConnectionToken.filter_not_expired(token=self.kwargs["token"]).first()
|
||||
token = ConnectionToken.filter_not_expired(
|
||||
token=self.kwargs["token"],
|
||||
session__session__session_key=request.session.session_key,
|
||||
).first()
|
||||
if not token:
|
||||
return redirect("authentik_core:if-user")
|
||||
self.token = token
|
||||
|
@ -23,6 +23,7 @@ from authentik.core.models import Application
|
||||
from authentik.events.models import Event, EventAction
|
||||
from authentik.lib.expression.exceptions import ControlFlowException
|
||||
from authentik.lib.sync.mapper import PropertyMappingManager
|
||||
from authentik.lib.utils.errors import exception_to_string
|
||||
from authentik.policies.api.exec import PolicyTestResultSerializer
|
||||
from authentik.policies.engine import PolicyEngine
|
||||
from authentik.policies.types import PolicyResult
|
||||
@ -141,9 +142,9 @@ class RadiusOutpostConfigViewSet(ListModelMixin, GenericViewSet):
|
||||
# Value error can be raised when assigning invalid data to an attribute
|
||||
Event.new(
|
||||
EventAction.CONFIGURATION_ERROR,
|
||||
message="Failed to evaluate property-mapping",
|
||||
message=f"Failed to evaluate property-mapping {exception_to_string(exc)}",
|
||||
mapping=exc.mapping,
|
||||
).with_exception(exc).save()
|
||||
).save()
|
||||
return None
|
||||
return b64encode(packet.RequestPacket()).decode()
|
||||
|
||||
|
@ -5,7 +5,6 @@ from itertools import batched
|
||||
from django.db import transaction
|
||||
from pydantic import ValidationError
|
||||
from pydanticscim.group import GroupMember
|
||||
from pydanticscim.responses import PatchOp
|
||||
|
||||
from authentik.core.models import Group
|
||||
from authentik.lib.sync.mapper import PropertyMappingManager
|
||||
@ -20,7 +19,12 @@ from authentik.providers.scim.clients.base import SCIMClient
|
||||
from authentik.providers.scim.clients.exceptions import (
|
||||
SCIMRequestException,
|
||||
)
|
||||
from authentik.providers.scim.clients.schema import SCIM_GROUP_SCHEMA, PatchOperation, PatchRequest
|
||||
from authentik.providers.scim.clients.schema import (
|
||||
SCIM_GROUP_SCHEMA,
|
||||
PatchOp,
|
||||
PatchOperation,
|
||||
PatchRequest,
|
||||
)
|
||||
from authentik.providers.scim.clients.schema import Group as SCIMGroupSchema
|
||||
from authentik.providers.scim.models import (
|
||||
SCIMMapping,
|
||||
|
@ -1,5 +1,7 @@
|
||||
"""Custom SCIM schemas"""
|
||||
|
||||
from enum import Enum
|
||||
|
||||
from pydantic import Field
|
||||
from pydanticscim.group import Group as BaseGroup
|
||||
from pydanticscim.responses import PatchOperation as BasePatchOperation
|
||||
@ -65,6 +67,21 @@ class ServiceProviderConfiguration(BaseServiceProviderConfiguration):
|
||||
)
|
||||
|
||||
|
||||
class PatchOp(str, Enum):
|
||||
|
||||
replace = "replace"
|
||||
remove = "remove"
|
||||
add = "add"
|
||||
|
||||
@classmethod
|
||||
def _missing_(cls, value):
|
||||
value = value.lower()
|
||||
for member in cls:
|
||||
if member.lower() == value:
|
||||
return member
|
||||
return None
|
||||
|
||||
|
||||
class PatchRequest(BasePatchRequest):
|
||||
"""PatchRequest which correctly sets schemas"""
|
||||
|
||||
@ -74,6 +91,7 @@ class PatchRequest(BasePatchRequest):
|
||||
class PatchOperation(BasePatchOperation):
|
||||
"""PatchOperation with optional path"""
|
||||
|
||||
op: PatchOp
|
||||
path: str | None
|
||||
|
||||
|
||||
|
@ -27,7 +27,8 @@ from structlog.stdlib import get_logger
|
||||
from tenant_schemas_celery.app import CeleryApp as TenantAwareCeleryApp
|
||||
|
||||
from authentik import get_full_version
|
||||
from authentik.lib.sentry import before_send
|
||||
from authentik.lib.sentry import should_ignore_exception
|
||||
from authentik.lib.utils.errors import exception_to_string
|
||||
|
||||
# set the default Django settings module for the 'celery' program.
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "authentik.root.settings")
|
||||
@ -80,10 +81,10 @@ def task_error_hook(task_id: str, exception: Exception, traceback, *args, **kwar
|
||||
|
||||
LOGGER.warning("Task failure", task_id=task_id.replace("-", ""), exc=exception)
|
||||
CTX_TASK_ID.set(...)
|
||||
if before_send({}, {"exc_info": (None, exception, None)}) is not None:
|
||||
if not should_ignore_exception(exception):
|
||||
Event.new(
|
||||
EventAction.SYSTEM_EXCEPTION, message="Failed to execute task", task_id=task_id
|
||||
).with_exception(exception).save()
|
||||
EventAction.SYSTEM_EXCEPTION, message=exception_to_string(exception), task_id=task_id
|
||||
).save()
|
||||
|
||||
|
||||
def _get_startup_tasks_default_tenant() -> list[Callable]:
|
||||
|
@ -1,13 +1,49 @@
|
||||
"""authentik database backend"""
|
||||
|
||||
from django.core.checks import Warning
|
||||
from django.db.backends.base.validation import BaseDatabaseValidation
|
||||
from django_tenants.postgresql_backend.base import DatabaseWrapper as BaseDatabaseWrapper
|
||||
|
||||
from authentik.lib.config import CONFIG
|
||||
|
||||
|
||||
class DatabaseValidation(BaseDatabaseValidation):
|
||||
|
||||
def check(self, **kwargs):
|
||||
return self._check_encoding()
|
||||
|
||||
def _check_encoding(self):
|
||||
"""Throw a warning when the server_encoding is not UTF-8 or
|
||||
server_encoding and client_encoding are mismatched"""
|
||||
messages = []
|
||||
with self.connection.cursor() as cursor:
|
||||
cursor.execute("SHOW server_encoding;")
|
||||
server_encoding = cursor.fetchone()[0]
|
||||
cursor.execute("SHOW client_encoding;")
|
||||
client_encoding = cursor.fetchone()[0]
|
||||
if server_encoding != client_encoding:
|
||||
messages.append(
|
||||
Warning(
|
||||
"PostgreSQL Server and Client encoding are mismatched: Server: "
|
||||
f"{server_encoding}, Client: {client_encoding}",
|
||||
id="ak.db.W001",
|
||||
)
|
||||
)
|
||||
if server_encoding != "UTF8":
|
||||
messages.append(
|
||||
Warning(
|
||||
f"PostgreSQL Server encoding is not UTF8: {server_encoding}",
|
||||
id="ak.db.W002",
|
||||
)
|
||||
)
|
||||
return messages
|
||||
|
||||
|
||||
class DatabaseWrapper(BaseDatabaseWrapper):
|
||||
"""database backend which supports rotating credentials"""
|
||||
|
||||
validation_class = DatabaseValidation
|
||||
|
||||
def get_connection_params(self):
|
||||
"""Refresh DB credentials before getting connection params"""
|
||||
conn_params = super().get_connection_params()
|
||||
|
@ -61,6 +61,22 @@ class SessionMiddleware(UpstreamSessionMiddleware):
|
||||
pass
|
||||
return session_key
|
||||
|
||||
@staticmethod
|
||||
def encode_session(session_key: str, user: User):
|
||||
payload = {
|
||||
"sid": session_key,
|
||||
"iss": "authentik",
|
||||
"sub": "anonymous",
|
||||
"authenticated": user.is_authenticated,
|
||||
"acr": ACR_AUTHENTIK_SESSION,
|
||||
}
|
||||
if user.is_authenticated:
|
||||
payload["sub"] = user.uid
|
||||
value = encode(payload=payload, key=SIGNING_HASH)
|
||||
if settings.TEST:
|
||||
value = session_key
|
||||
return value
|
||||
|
||||
def process_request(self, request: HttpRequest):
|
||||
raw_session = request.COOKIES.get(settings.SESSION_COOKIE_NAME)
|
||||
session_key = SessionMiddleware.decode_session_key(raw_session)
|
||||
@ -117,21 +133,9 @@ class SessionMiddleware(UpstreamSessionMiddleware):
|
||||
"request completed. The user may have logged "
|
||||
"out in a concurrent request, for example."
|
||||
) from None
|
||||
payload = {
|
||||
"sid": request.session.session_key,
|
||||
"iss": "authentik",
|
||||
"sub": "anonymous",
|
||||
"authenticated": request.user.is_authenticated,
|
||||
"acr": ACR_AUTHENTIK_SESSION,
|
||||
}
|
||||
if request.user.is_authenticated:
|
||||
payload["sub"] = request.user.uid
|
||||
value = encode(payload=payload, key=SIGNING_HASH)
|
||||
if settings.TEST:
|
||||
value = request.session.session_key
|
||||
response.set_cookie(
|
||||
settings.SESSION_COOKIE_NAME,
|
||||
value,
|
||||
SessionMiddleware.encode_session(request.session.session_key, request.user),
|
||||
max_age=max_age,
|
||||
expires=expires,
|
||||
domain=settings.SESSION_COOKIE_DOMAIN,
|
||||
|
@ -8,6 +8,7 @@ from authentik.events.models import TaskStatus
|
||||
from authentik.events.system_tasks import SystemTask
|
||||
from authentik.lib.config import CONFIG
|
||||
from authentik.lib.sync.outgoing.exceptions import StopSync
|
||||
from authentik.lib.utils.errors import exception_to_string
|
||||
from authentik.root.celery import CELERY_APP
|
||||
from authentik.sources.kerberos.models import KerberosSource
|
||||
from authentik.sources.kerberos.sync import KerberosSync
|
||||
@ -63,5 +64,5 @@ def kerberos_sync_single(self, source_pk: str):
|
||||
syncer.sync()
|
||||
self.set_status(TaskStatus.SUCCESSFUL, *syncer.messages)
|
||||
except StopSync as exc:
|
||||
LOGGER.warning("Error syncing kerberos", exc=exc, source=source)
|
||||
LOGGER.warning(exception_to_string(exc))
|
||||
self.set_error(exc)
|
||||
|
@ -12,6 +12,7 @@ from authentik.events.models import TaskStatus
|
||||
from authentik.events.system_tasks import SystemTask
|
||||
from authentik.lib.config import CONFIG
|
||||
from authentik.lib.sync.outgoing.exceptions import StopSync
|
||||
from authentik.lib.utils.errors import exception_to_string
|
||||
from authentik.lib.utils.reflection import class_to_path, path_to_class
|
||||
from authentik.root.celery import CELERY_APP
|
||||
from authentik.sources.ldap.models import LDAPSource
|
||||
@ -148,5 +149,5 @@ def ldap_sync(self: SystemTask, source_pk: str, sync_class: str, page_cache_key:
|
||||
cache.delete(page_cache_key)
|
||||
except (LDAPException, StopSync) as exc:
|
||||
# No explicit event is created here as .set_status with an error will do that
|
||||
LOGGER.warning("Failed to sync LDAP", exc=exc, source=source)
|
||||
LOGGER.warning(exception_to_string(exc))
|
||||
self.set_error(exc)
|
||||
|
277
authentik/sources/scim/tests/test_groups.py
Normal file
277
authentik/sources/scim/tests/test_groups.py
Normal file
@ -0,0 +1,277 @@
|
||||
"""Test SCIM Group"""
|
||||
|
||||
from json import dumps
|
||||
from uuid import uuid4
|
||||
|
||||
from django.urls import reverse
|
||||
from rest_framework.test import APITestCase
|
||||
|
||||
from authentik.core.models import Group
|
||||
from authentik.core.tests.utils import create_test_user
|
||||
from authentik.events.models import Event, EventAction
|
||||
from authentik.lib.generators import generate_id
|
||||
from authentik.providers.scim.clients.schema import Group as SCIMGroupSchema
|
||||
from authentik.sources.scim.models import (
|
||||
SCIMSource,
|
||||
SCIMSourceGroup,
|
||||
)
|
||||
from authentik.sources.scim.views.v2.base import SCIM_CONTENT_TYPE
|
||||
|
||||
|
||||
class TestSCIMGroups(APITestCase):
|
||||
"""Test SCIM Group view"""
|
||||
|
||||
def setUp(self) -> None:
|
||||
self.source = SCIMSource.objects.create(name=generate_id(), slug=generate_id())
|
||||
|
||||
def test_group_list(self):
|
||||
"""Test full group list"""
|
||||
response = self.client.get(
|
||||
reverse(
|
||||
"authentik_sources_scim:v2-groups",
|
||||
kwargs={
|
||||
"source_slug": self.source.slug,
|
||||
},
|
||||
),
|
||||
HTTP_AUTHORIZATION=f"Bearer {self.source.token.key}",
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_group_list_single(self):
|
||||
"""Test full group list (single group)"""
|
||||
group = Group.objects.create(name=generate_id())
|
||||
user = create_test_user()
|
||||
group.users.add(user)
|
||||
SCIMSourceGroup.objects.create(
|
||||
source=self.source,
|
||||
group=group,
|
||||
id=str(uuid4()),
|
||||
)
|
||||
response = self.client.get(
|
||||
reverse(
|
||||
"authentik_sources_scim:v2-groups",
|
||||
kwargs={
|
||||
"source_slug": self.source.slug,
|
||||
"group_id": str(group.pk),
|
||||
},
|
||||
),
|
||||
HTTP_AUTHORIZATION=f"Bearer {self.source.token.key}",
|
||||
)
|
||||
self.assertEqual(response.status_code, second=200)
|
||||
SCIMGroupSchema.model_validate_json(response.content, strict=True)
|
||||
|
||||
def test_group_create(self):
|
||||
"""Test group create"""
|
||||
ext_id = generate_id()
|
||||
response = self.client.post(
|
||||
reverse(
|
||||
"authentik_sources_scim:v2-groups",
|
||||
kwargs={
|
||||
"source_slug": self.source.slug,
|
||||
},
|
||||
),
|
||||
data=dumps({"displayName": generate_id(), "externalId": ext_id}),
|
||||
content_type=SCIM_CONTENT_TYPE,
|
||||
HTTP_AUTHORIZATION=f"Bearer {self.source.token.key}",
|
||||
)
|
||||
self.assertEqual(response.status_code, 201)
|
||||
self.assertTrue(SCIMSourceGroup.objects.filter(source=self.source, id=ext_id).exists())
|
||||
self.assertTrue(
|
||||
Event.objects.filter(
|
||||
action=EventAction.MODEL_CREATED, user__username=self.source.token.user.username
|
||||
).exists()
|
||||
)
|
||||
|
||||
def test_group_create_members(self):
|
||||
"""Test group create"""
|
||||
user = create_test_user()
|
||||
ext_id = generate_id()
|
||||
response = self.client.post(
|
||||
reverse(
|
||||
"authentik_sources_scim:v2-groups",
|
||||
kwargs={
|
||||
"source_slug": self.source.slug,
|
||||
},
|
||||
),
|
||||
data=dumps(
|
||||
{
|
||||
"displayName": generate_id(),
|
||||
"externalId": ext_id,
|
||||
"members": [{"value": str(user.uuid)}],
|
||||
}
|
||||
),
|
||||
content_type=SCIM_CONTENT_TYPE,
|
||||
HTTP_AUTHORIZATION=f"Bearer {self.source.token.key}",
|
||||
)
|
||||
self.assertEqual(response.status_code, 201)
|
||||
self.assertTrue(SCIMSourceGroup.objects.filter(source=self.source, id=ext_id).exists())
|
||||
self.assertTrue(
|
||||
Event.objects.filter(
|
||||
action=EventAction.MODEL_CREATED, user__username=self.source.token.user.username
|
||||
).exists()
|
||||
)
|
||||
|
||||
def test_group_create_members_empty(self):
|
||||
"""Test group create"""
|
||||
ext_id = generate_id()
|
||||
response = self.client.post(
|
||||
reverse(
|
||||
"authentik_sources_scim:v2-groups",
|
||||
kwargs={
|
||||
"source_slug": self.source.slug,
|
||||
},
|
||||
),
|
||||
data=dumps({"displayName": generate_id(), "externalId": ext_id, "members": []}),
|
||||
content_type=SCIM_CONTENT_TYPE,
|
||||
HTTP_AUTHORIZATION=f"Bearer {self.source.token.key}",
|
||||
)
|
||||
self.assertEqual(response.status_code, 201)
|
||||
self.assertTrue(SCIMSourceGroup.objects.filter(source=self.source, id=ext_id).exists())
|
||||
self.assertTrue(
|
||||
Event.objects.filter(
|
||||
action=EventAction.MODEL_CREATED, user__username=self.source.token.user.username
|
||||
).exists()
|
||||
)
|
||||
|
||||
def test_group_create_duplicate(self):
|
||||
"""Test group create (duplicate)"""
|
||||
group = Group.objects.create(name=generate_id())
|
||||
existing = SCIMSourceGroup.objects.create(source=self.source, group=group, id=uuid4())
|
||||
ext_id = generate_id()
|
||||
response = self.client.post(
|
||||
reverse(
|
||||
"authentik_sources_scim:v2-groups",
|
||||
kwargs={
|
||||
"source_slug": self.source.slug,
|
||||
},
|
||||
),
|
||||
data=dumps(
|
||||
{"displayName": generate_id(), "externalId": ext_id, "id": str(existing.group.pk)}
|
||||
),
|
||||
content_type=SCIM_CONTENT_TYPE,
|
||||
HTTP_AUTHORIZATION=f"Bearer {self.source.token.key}",
|
||||
)
|
||||
self.assertEqual(response.status_code, 409)
|
||||
self.assertJSONEqual(
|
||||
response.content,
|
||||
{
|
||||
"detail": "Group with ID exists already.",
|
||||
"schemas": ["urn:ietf:params:scim:api:messages:2.0:Error"],
|
||||
"scimType": "uniqueness",
|
||||
"status": 409,
|
||||
},
|
||||
)
|
||||
|
||||
def test_group_update(self):
|
||||
"""Test group update"""
|
||||
group = Group.objects.create(name=generate_id())
|
||||
existing = SCIMSourceGroup.objects.create(source=self.source, group=group, id=uuid4())
|
||||
ext_id = generate_id()
|
||||
response = self.client.put(
|
||||
reverse(
|
||||
"authentik_sources_scim:v2-groups",
|
||||
kwargs={"source_slug": self.source.slug, "group_id": group.pk},
|
||||
),
|
||||
data=dumps(
|
||||
{"displayName": generate_id(), "externalId": ext_id, "id": str(existing.pk)}
|
||||
),
|
||||
content_type=SCIM_CONTENT_TYPE,
|
||||
HTTP_AUTHORIZATION=f"Bearer {self.source.token.key}",
|
||||
)
|
||||
self.assertEqual(response.status_code, second=200)
|
||||
|
||||
def test_group_update_non_existent(self):
|
||||
"""Test group update"""
|
||||
ext_id = generate_id()
|
||||
response = self.client.put(
|
||||
reverse(
|
||||
"authentik_sources_scim:v2-groups",
|
||||
kwargs={
|
||||
"source_slug": self.source.slug,
|
||||
"group_id": str(uuid4()),
|
||||
},
|
||||
),
|
||||
data=dumps({"displayName": generate_id(), "externalId": ext_id, "id": ""}),
|
||||
content_type=SCIM_CONTENT_TYPE,
|
||||
HTTP_AUTHORIZATION=f"Bearer {self.source.token.key}",
|
||||
)
|
||||
self.assertEqual(response.status_code, second=404)
|
||||
self.assertJSONEqual(
|
||||
response.content,
|
||||
{
|
||||
"detail": "Group not found.",
|
||||
"schemas": ["urn:ietf:params:scim:api:messages:2.0:Error"],
|
||||
"status": 404,
|
||||
},
|
||||
)
|
||||
|
||||
def test_group_patch_add(self):
|
||||
"""Test group patch"""
|
||||
user = create_test_user()
|
||||
|
||||
group = Group.objects.create(name=generate_id())
|
||||
SCIMSourceGroup.objects.create(source=self.source, group=group, id=uuid4())
|
||||
response = self.client.patch(
|
||||
reverse(
|
||||
"authentik_sources_scim:v2-groups",
|
||||
kwargs={"source_slug": self.source.slug, "group_id": group.pk},
|
||||
),
|
||||
data=dumps(
|
||||
{
|
||||
"Operations": [
|
||||
{
|
||||
"op": "Add",
|
||||
"path": "members",
|
||||
"value": {"value": str(user.uuid)},
|
||||
}
|
||||
]
|
||||
}
|
||||
),
|
||||
content_type=SCIM_CONTENT_TYPE,
|
||||
HTTP_AUTHORIZATION=f"Bearer {self.source.token.key}",
|
||||
)
|
||||
self.assertEqual(response.status_code, second=200)
|
||||
self.assertTrue(group.users.filter(pk=user.pk).exists())
|
||||
|
||||
def test_group_patch_remove(self):
|
||||
"""Test group patch"""
|
||||
user = create_test_user()
|
||||
|
||||
group = Group.objects.create(name=generate_id())
|
||||
group.users.add(user)
|
||||
SCIMSourceGroup.objects.create(source=self.source, group=group, id=uuid4())
|
||||
response = self.client.patch(
|
||||
reverse(
|
||||
"authentik_sources_scim:v2-groups",
|
||||
kwargs={"source_slug": self.source.slug, "group_id": group.pk},
|
||||
),
|
||||
data=dumps(
|
||||
{
|
||||
"Operations": [
|
||||
{
|
||||
"op": "remove",
|
||||
"path": "members",
|
||||
"value": {"value": str(user.uuid)},
|
||||
}
|
||||
]
|
||||
}
|
||||
),
|
||||
content_type=SCIM_CONTENT_TYPE,
|
||||
HTTP_AUTHORIZATION=f"Bearer {self.source.token.key}",
|
||||
)
|
||||
self.assertEqual(response.status_code, second=200)
|
||||
self.assertFalse(group.users.filter(pk=user.pk).exists())
|
||||
|
||||
def test_group_delete(self):
|
||||
"""Test group delete"""
|
||||
group = Group.objects.create(name=generate_id())
|
||||
SCIMSourceGroup.objects.create(source=self.source, group=group, id=uuid4())
|
||||
response = self.client.delete(
|
||||
reverse(
|
||||
"authentik_sources_scim:v2-groups",
|
||||
kwargs={"source_slug": self.source.slug, "group_id": group.pk},
|
||||
),
|
||||
content_type=SCIM_CONTENT_TYPE,
|
||||
HTTP_AUTHORIZATION=f"Bearer {self.source.token.key}",
|
||||
)
|
||||
self.assertEqual(response.status_code, second=204)
|
@ -177,3 +177,51 @@ class TestSCIMUsers(APITestCase):
|
||||
SCIMSourceUser.objects.get(source=self.source, id=ext_id).user.attributes["phone"],
|
||||
"0123456789",
|
||||
)
|
||||
|
||||
def test_user_update(self):
|
||||
"""Test user update"""
|
||||
user = create_test_user()
|
||||
existing = SCIMSourceUser.objects.create(source=self.source, user=user, id=uuid4())
|
||||
ext_id = generate_id()
|
||||
response = self.client.put(
|
||||
reverse(
|
||||
"authentik_sources_scim:v2-users",
|
||||
kwargs={
|
||||
"source_slug": self.source.slug,
|
||||
"user_id": str(user.uuid),
|
||||
},
|
||||
),
|
||||
data=dumps(
|
||||
{
|
||||
"id": str(existing.pk),
|
||||
"userName": generate_id(),
|
||||
"externalId": ext_id,
|
||||
"emails": [
|
||||
{
|
||||
"primary": True,
|
||||
"value": user.email,
|
||||
}
|
||||
],
|
||||
}
|
||||
),
|
||||
content_type=SCIM_CONTENT_TYPE,
|
||||
HTTP_AUTHORIZATION=f"Bearer {self.source.token.key}",
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_user_delete(self):
|
||||
"""Test user delete"""
|
||||
user = create_test_user()
|
||||
SCIMSourceUser.objects.create(source=self.source, user=user, id=uuid4())
|
||||
response = self.client.delete(
|
||||
reverse(
|
||||
"authentik_sources_scim:v2-users",
|
||||
kwargs={
|
||||
"source_slug": self.source.slug,
|
||||
"user_id": str(user.uuid),
|
||||
},
|
||||
),
|
||||
content_type=SCIM_CONTENT_TYPE,
|
||||
HTTP_AUTHORIZATION=f"Bearer {self.source.token.key}",
|
||||
)
|
||||
self.assertEqual(response.status_code, 204)
|
||||
|
@ -8,6 +8,7 @@ from rest_framework.authentication import BaseAuthentication, get_authorization_
|
||||
from rest_framework.request import Request
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from authentik.core.middleware import CTX_AUTH_VIA
|
||||
from authentik.core.models import Token, TokenIntents, User
|
||||
from authentik.sources.scim.models import SCIMSource
|
||||
|
||||
@ -26,6 +27,7 @@ class SCIMTokenAuth(BaseAuthentication):
|
||||
_username, _, password = b64decode(key.encode()).decode().partition(":")
|
||||
token = self.check_token(password, source_slug)
|
||||
if token:
|
||||
CTX_AUTH_VIA.set("scim_basic")
|
||||
return (token.user, token)
|
||||
return None
|
||||
|
||||
@ -52,4 +54,5 @@ class SCIMTokenAuth(BaseAuthentication):
|
||||
token = self.check_token(key, source_slug)
|
||||
if not token:
|
||||
return None
|
||||
CTX_AUTH_VIA.set("scim_token")
|
||||
return (token.user, token)
|
||||
|
@ -1,13 +1,11 @@
|
||||
"""SCIM Utils"""
|
||||
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.paginator import Page, Paginator
|
||||
from django.db.models import Q, QuerySet
|
||||
from django.http import HttpRequest
|
||||
from django.urls import resolve
|
||||
from rest_framework.parsers import JSONParser
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.renderers import JSONRenderer
|
||||
@ -46,7 +44,7 @@ class SCIMView(APIView):
|
||||
logger: BoundLogger
|
||||
|
||||
permission_classes = [IsAuthenticated]
|
||||
parser_classes = [SCIMParser]
|
||||
parser_classes = [SCIMParser, JSONParser]
|
||||
renderer_classes = [SCIMRenderer]
|
||||
|
||||
def setup(self, request: HttpRequest, *args: Any, **kwargs: Any) -> None:
|
||||
@ -56,28 +54,6 @@ class SCIMView(APIView):
|
||||
def get_authenticators(self):
|
||||
return [SCIMTokenAuth(self)]
|
||||
|
||||
def patch_resolve_value(self, raw_value: dict) -> User | Group | None:
|
||||
"""Attempt to resolve a raw `value` attribute of a patch operation into
|
||||
a database model"""
|
||||
model = User
|
||||
query = {}
|
||||
if "$ref" in raw_value:
|
||||
url = urlparse(raw_value["$ref"])
|
||||
if match := resolve(url.path):
|
||||
if match.url_name == "v2-users":
|
||||
model = User
|
||||
query = {"pk": int(match.kwargs["user_id"])}
|
||||
elif "type" in raw_value:
|
||||
match raw_value["type"]:
|
||||
case "User":
|
||||
model = User
|
||||
query = {"pk": int(raw_value["value"])}
|
||||
case "Group":
|
||||
model = Group
|
||||
else:
|
||||
return None
|
||||
return model.objects.filter(**query).first()
|
||||
|
||||
def filter_parse(self, request: Request):
|
||||
"""Parse the path of a Patch Operation"""
|
||||
path = request.query_params.get("filter")
|
||||
|
58
authentik/sources/scim/views/v2/exceptions.py
Normal file
58
authentik/sources/scim/views/v2/exceptions.py
Normal file
@ -0,0 +1,58 @@
|
||||
from enum import Enum
|
||||
|
||||
from pydanticscim.responses import SCIMError as BaseSCIMError
|
||||
from rest_framework.exceptions import ValidationError
|
||||
|
||||
|
||||
class SCIMErrorTypes(Enum):
|
||||
invalid_filter = "invalidFilter"
|
||||
too_many = "tooMany"
|
||||
uniqueness = "uniqueness"
|
||||
mutability = "mutability"
|
||||
invalid_syntax = "invalidSyntax"
|
||||
invalid_path = "invalidPath"
|
||||
no_target = "noTarget"
|
||||
invalid_value = "invalidValue"
|
||||
invalid_vers = "invalidVers"
|
||||
sensitive = "sensitive"
|
||||
|
||||
|
||||
class SCIMError(BaseSCIMError):
|
||||
scimType: SCIMErrorTypes | None = None
|
||||
detail: str | None = None
|
||||
|
||||
|
||||
class SCIMValidationError(ValidationError):
|
||||
status_code = 400
|
||||
default_detail = SCIMError(scimType=SCIMErrorTypes.invalid_syntax, status=400)
|
||||
|
||||
def __init__(self, detail: SCIMError | None):
|
||||
if detail is None:
|
||||
detail = self.default_detail
|
||||
detail.status = self.status_code
|
||||
self.detail = detail.model_dump(mode="json", exclude_none=True)
|
||||
|
||||
|
||||
class SCIMConflictError(SCIMValidationError):
|
||||
status_code = 409
|
||||
|
||||
def __init__(self, detail: str):
|
||||
super().__init__(
|
||||
SCIMError(
|
||||
detail=detail,
|
||||
scimType=SCIMErrorTypes.uniqueness,
|
||||
status=self.status_code,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class SCIMNotFoundError(SCIMValidationError):
|
||||
status_code = 404
|
||||
|
||||
def __init__(self, detail: str):
|
||||
super().__init__(
|
||||
SCIMError(
|
||||
detail=detail,
|
||||
status=self.status_code,
|
||||
)
|
||||
)
|
@ -4,19 +4,25 @@ from uuid import uuid4
|
||||
|
||||
from django.db.models import Q
|
||||
from django.db.transaction import atomic
|
||||
from django.http import Http404, QueryDict
|
||||
from django.http import QueryDict
|
||||
from django.urls import reverse
|
||||
from pydantic import ValidationError as PydanticValidationError
|
||||
from pydanticscim.group import GroupMember
|
||||
from rest_framework.exceptions import ValidationError
|
||||
from rest_framework.request import Request
|
||||
from rest_framework.response import Response
|
||||
from scim2_filter_parser.attr_paths import AttrPath
|
||||
|
||||
from authentik.core.models import Group, User
|
||||
from authentik.providers.scim.clients.schema import SCIM_USER_SCHEMA
|
||||
from authentik.providers.scim.clients.schema import SCIM_GROUP_SCHEMA, PatchOp, PatchOperation
|
||||
from authentik.providers.scim.clients.schema import Group as SCIMGroupModel
|
||||
from authentik.sources.scim.models import SCIMSourceGroup
|
||||
from authentik.sources.scim.views.v2.base import SCIMObjectView
|
||||
from authentik.sources.scim.views.v2.exceptions import (
|
||||
SCIMConflictError,
|
||||
SCIMNotFoundError,
|
||||
SCIMValidationError,
|
||||
)
|
||||
|
||||
|
||||
class GroupsView(SCIMObjectView):
|
||||
@ -27,7 +33,7 @@ class GroupsView(SCIMObjectView):
|
||||
def group_to_scim(self, scim_group: SCIMSourceGroup) -> dict:
|
||||
"""Convert Group to SCIM data"""
|
||||
payload = SCIMGroupModel(
|
||||
schemas=[SCIM_USER_SCHEMA],
|
||||
schemas=[SCIM_GROUP_SCHEMA],
|
||||
id=str(scim_group.group.pk),
|
||||
externalId=scim_group.id,
|
||||
displayName=scim_group.group.name,
|
||||
@ -58,7 +64,7 @@ class GroupsView(SCIMObjectView):
|
||||
if group_id:
|
||||
connection = base_query.filter(source=self.source, group__group_uuid=group_id).first()
|
||||
if not connection:
|
||||
raise Http404
|
||||
raise SCIMNotFoundError("Group not found.")
|
||||
return Response(self.group_to_scim(connection))
|
||||
connections = (
|
||||
base_query.filter(source=self.source).order_by("pk").filter(self.filter_parse(request))
|
||||
@ -119,7 +125,7 @@ class GroupsView(SCIMObjectView):
|
||||
).first()
|
||||
if connection:
|
||||
self.logger.debug("Found existing group")
|
||||
return Response(status=409)
|
||||
raise SCIMConflictError("Group with ID exists already.")
|
||||
connection = self.update_group(None, request.data)
|
||||
return Response(self.group_to_scim(connection), status=201)
|
||||
|
||||
@ -129,10 +135,44 @@ class GroupsView(SCIMObjectView):
|
||||
source=self.source, group__group_uuid=group_id
|
||||
).first()
|
||||
if not connection:
|
||||
raise Http404
|
||||
raise SCIMNotFoundError("Group not found.")
|
||||
connection = self.update_group(connection, request.data)
|
||||
return Response(self.group_to_scim(connection), status=200)
|
||||
|
||||
@atomic
|
||||
def patch(self, request: Request, group_id: str, **kwargs) -> Response:
|
||||
"""Patch group handler"""
|
||||
connection = SCIMSourceGroup.objects.filter(
|
||||
source=self.source, group__group_uuid=group_id
|
||||
).first()
|
||||
if not connection:
|
||||
raise SCIMNotFoundError("Group not found.")
|
||||
|
||||
for _op in request.data.get("Operations", []):
|
||||
operation = PatchOperation.model_validate(_op)
|
||||
if operation.op.lower() not in ["add", "remove", "replace"]:
|
||||
raise SCIMValidationError()
|
||||
attr_path = AttrPath(f'{operation.path} eq ""', {})
|
||||
if attr_path.first_path == ("members", None, None):
|
||||
# FIXME: this can probably be de-duplicated
|
||||
if operation.op == PatchOp.add:
|
||||
if not isinstance(operation.value, list):
|
||||
operation.value = [operation.value]
|
||||
query = Q()
|
||||
for member in operation.value:
|
||||
query |= Q(uuid=member["value"])
|
||||
if query:
|
||||
connection.group.users.add(*User.objects.filter(query))
|
||||
elif operation.op == PatchOp.remove:
|
||||
if not isinstance(operation.value, list):
|
||||
operation.value = [operation.value]
|
||||
query = Q()
|
||||
for member in operation.value:
|
||||
query |= Q(uuid=member["value"])
|
||||
if query:
|
||||
connection.group.users.remove(*User.objects.filter(query))
|
||||
return Response(self.group_to_scim(connection), status=200)
|
||||
|
||||
@atomic
|
||||
def delete(self, request: Request, group_id: str, **kwargs) -> Response:
|
||||
"""Delete group handler"""
|
||||
@ -140,7 +180,7 @@ class GroupsView(SCIMObjectView):
|
||||
source=self.source, group__group_uuid=group_id
|
||||
).first()
|
||||
if not connection:
|
||||
raise Http404
|
||||
raise SCIMNotFoundError("Group not found.")
|
||||
connection.group.delete()
|
||||
connection.delete()
|
||||
return Response(status=204)
|
||||
|
@ -1,11 +1,11 @@
|
||||
"""SCIM Meta views"""
|
||||
|
||||
from django.http import Http404
|
||||
from django.urls import reverse
|
||||
from rest_framework.request import Request
|
||||
from rest_framework.response import Response
|
||||
|
||||
from authentik.sources.scim.views.v2.base import SCIMView
|
||||
from authentik.sources.scim.views.v2.exceptions import SCIMNotFoundError
|
||||
|
||||
|
||||
class ResourceTypesView(SCIMView):
|
||||
@ -138,7 +138,7 @@ class ResourceTypesView(SCIMView):
|
||||
resource = [x for x in resource_types if x.get("id") == resource_type]
|
||||
if resource:
|
||||
return Response(resource[0])
|
||||
raise Http404
|
||||
raise SCIMNotFoundError("Resource not found.")
|
||||
return Response(
|
||||
{
|
||||
"schemas": ["urn:ietf:params:scim:api:messages:2.0:ListResponse"],
|
||||
|
@ -3,12 +3,12 @@
|
||||
from json import loads
|
||||
|
||||
from django.conf import settings
|
||||
from django.http import Http404
|
||||
from django.urls import reverse
|
||||
from rest_framework.request import Request
|
||||
from rest_framework.response import Response
|
||||
|
||||
from authentik.sources.scim.views.v2.base import SCIMView
|
||||
from authentik.sources.scim.views.v2.exceptions import SCIMNotFoundError
|
||||
|
||||
with open(
|
||||
settings.BASE_DIR / "authentik" / "sources" / "scim" / "schemas" / "schema.json",
|
||||
@ -44,7 +44,7 @@ class SchemaView(SCIMView):
|
||||
schema = [x for x in schemas if x.get("id") == schema_uri]
|
||||
if schema:
|
||||
return Response(schema[0])
|
||||
raise Http404
|
||||
raise SCIMNotFoundError("Schema not found.")
|
||||
return Response(
|
||||
{
|
||||
"schemas": ["urn:ietf:params:scim:api:messages:2.0:ListResponse"],
|
||||
|
@ -33,6 +33,8 @@ class ServiceProviderConfigView(SCIMView):
|
||||
{
|
||||
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig"],
|
||||
"authenticationSchemes": auth_schemas,
|
||||
# We only support patch for groups currently, so don't broadly advertise it.
|
||||
# Implementations that require Group patch will use it regardless of this flag.
|
||||
"patch": {"supported": False},
|
||||
"bulk": {"supported": False, "maxOperations": 0, "maxPayloadSize": 0},
|
||||
"filter": {
|
||||
|
@ -4,7 +4,7 @@ from uuid import uuid4
|
||||
|
||||
from django.db.models import Q
|
||||
from django.db.transaction import atomic
|
||||
from django.http import Http404, QueryDict
|
||||
from django.http import QueryDict
|
||||
from django.urls import reverse
|
||||
from pydanticscim.user import Email, EmailKind, Name
|
||||
from rest_framework.exceptions import ValidationError
|
||||
@ -16,6 +16,7 @@ from authentik.providers.scim.clients.schema import SCIM_USER_SCHEMA
|
||||
from authentik.providers.scim.clients.schema import User as SCIMUserModel
|
||||
from authentik.sources.scim.models import SCIMSourceUser
|
||||
from authentik.sources.scim.views.v2.base import SCIMObjectView
|
||||
from authentik.sources.scim.views.v2.exceptions import SCIMConflictError, SCIMNotFoundError
|
||||
|
||||
|
||||
class UsersView(SCIMObjectView):
|
||||
@ -69,7 +70,7 @@ class UsersView(SCIMObjectView):
|
||||
.first()
|
||||
)
|
||||
if not connection:
|
||||
raise Http404
|
||||
raise SCIMNotFoundError("User not found.")
|
||||
return Response(self.user_to_scim(connection))
|
||||
connections = (
|
||||
SCIMSourceUser.objects.filter(source=self.source).select_related("user").order_by("pk")
|
||||
@ -122,7 +123,7 @@ class UsersView(SCIMObjectView):
|
||||
).first()
|
||||
if connection:
|
||||
self.logger.debug("Found existing user")
|
||||
return Response(status=409)
|
||||
raise SCIMConflictError("Group with ID exists already.")
|
||||
connection = self.update_user(None, request.data)
|
||||
return Response(self.user_to_scim(connection), status=201)
|
||||
|
||||
@ -130,7 +131,7 @@ class UsersView(SCIMObjectView):
|
||||
"""Update user handler"""
|
||||
connection = SCIMSourceUser.objects.filter(source=self.source, user__uuid=user_id).first()
|
||||
if not connection:
|
||||
raise Http404
|
||||
raise SCIMNotFoundError("User not found.")
|
||||
self.update_user(connection, request.data)
|
||||
return Response(self.user_to_scim(connection), status=200)
|
||||
|
||||
@ -139,7 +140,7 @@ class UsersView(SCIMObjectView):
|
||||
"""Delete user handler"""
|
||||
connection = SCIMSourceUser.objects.filter(source=self.source, user__uuid=user_id).first()
|
||||
if not connection:
|
||||
raise Http404
|
||||
raise SCIMNotFoundError("User not found.")
|
||||
connection.user.delete()
|
||||
connection.delete()
|
||||
return Response(status=204)
|
||||
|
@ -13,6 +13,7 @@ from authentik.flows.exceptions import StageInvalidException
|
||||
from authentik.flows.models import ConfigurableStage, FriendlyNamedStage, Stage
|
||||
from authentik.lib.config import CONFIG
|
||||
from authentik.lib.models import SerializerModel
|
||||
from authentik.lib.utils.errors import exception_to_string
|
||||
from authentik.lib.utils.time import timedelta_string_validator
|
||||
from authentik.stages.authenticator.models import SideChannelDevice
|
||||
from authentik.stages.email.utils import TemplateEmailMessage
|
||||
@ -159,8 +160,9 @@ class EmailDevice(SerializerModel, SideChannelDevice):
|
||||
Event.new(
|
||||
EventAction.CONFIGURATION_ERROR,
|
||||
message=_("Exception occurred while rendering E-mail template"),
|
||||
error=exception_to_string(exc),
|
||||
template=stage.template,
|
||||
).with_exception(exc).from_http(self.request)
|
||||
).from_http(self.request)
|
||||
raise StageInvalidException from exc
|
||||
|
||||
def __str__(self):
|
||||
|
@ -17,6 +17,7 @@ from authentik.flows.challenge import (
|
||||
from authentik.flows.exceptions import StageInvalidException
|
||||
from authentik.flows.stage import ChallengeStageView
|
||||
from authentik.lib.utils.email import mask_email
|
||||
from authentik.lib.utils.errors import exception_to_string
|
||||
from authentik.lib.utils.time import timedelta_from_string
|
||||
from authentik.stages.authenticator_email.models import (
|
||||
AuthenticatorEmailStage,
|
||||
@ -99,8 +100,9 @@ class AuthenticatorEmailStageView(ChallengeStageView):
|
||||
Event.new(
|
||||
EventAction.CONFIGURATION_ERROR,
|
||||
message=_("Exception occurred while rendering E-mail template"),
|
||||
error=exception_to_string(exc),
|
||||
template=stage.template,
|
||||
).with_exception(exc).from_http(self.request)
|
||||
).from_http(self.request)
|
||||
raise StageInvalidException from exc
|
||||
|
||||
def _has_email(self) -> str | None:
|
||||
|
@ -19,6 +19,7 @@ from authentik.events.models import Event, EventAction, NotificationWebhookMappi
|
||||
from authentik.events.utils import sanitize_item
|
||||
from authentik.flows.models import ConfigurableStage, FriendlyNamedStage, Stage
|
||||
from authentik.lib.models import SerializerModel
|
||||
from authentik.lib.utils.errors import exception_to_string
|
||||
from authentik.lib.utils.http import get_http_session
|
||||
from authentik.stages.authenticator.models import SideChannelDevice
|
||||
|
||||
@ -141,9 +142,10 @@ class AuthenticatorSMSStage(ConfigurableStage, FriendlyNamedStage, Stage):
|
||||
Event.new(
|
||||
EventAction.CONFIGURATION_ERROR,
|
||||
message="Error sending SMS",
|
||||
exc=exception_to_string(exc),
|
||||
status_code=response.status_code,
|
||||
body=response.text,
|
||||
).with_exception(exc).set_user(device.user).save()
|
||||
).set_user(device.user).save()
|
||||
if response.status_code >= HttpResponseBadRequest.status_code:
|
||||
raise ValidationError(response.text) from None
|
||||
raise
|
||||
|
@ -1,6 +1,7 @@
|
||||
"""Validation stage challenge checking"""
|
||||
|
||||
from json import loads
|
||||
from typing import TYPE_CHECKING
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from django.http import HttpRequest
|
||||
@ -36,10 +37,12 @@ from authentik.stages.authenticator_email.models import EmailDevice
|
||||
from authentik.stages.authenticator_sms.models import SMSDevice
|
||||
from authentik.stages.authenticator_validate.models import AuthenticatorValidateStage, DeviceClasses
|
||||
from authentik.stages.authenticator_webauthn.models import UserVerification, WebAuthnDevice
|
||||
from authentik.stages.authenticator_webauthn.stage import SESSION_KEY_WEBAUTHN_CHALLENGE
|
||||
from authentik.stages.authenticator_webauthn.stage import PLAN_CONTEXT_WEBAUTHN_CHALLENGE
|
||||
from authentik.stages.authenticator_webauthn.utils import get_origin, get_rp_id
|
||||
|
||||
LOGGER = get_logger()
|
||||
if TYPE_CHECKING:
|
||||
from authentik.stages.authenticator_validate.stage import AuthenticatorValidateStageView
|
||||
|
||||
|
||||
class DeviceChallenge(PassiveSerializer):
|
||||
@ -52,11 +55,11 @@ class DeviceChallenge(PassiveSerializer):
|
||||
|
||||
|
||||
def get_challenge_for_device(
|
||||
request: HttpRequest, stage: AuthenticatorValidateStage, device: Device
|
||||
stage_view: "AuthenticatorValidateStageView", stage: AuthenticatorValidateStage, device: Device
|
||||
) -> dict:
|
||||
"""Generate challenge for a single device"""
|
||||
if isinstance(device, WebAuthnDevice):
|
||||
return get_webauthn_challenge(request, stage, device)
|
||||
return get_webauthn_challenge(stage_view, stage, device)
|
||||
if isinstance(device, EmailDevice):
|
||||
return {"email": mask_email(device.email)}
|
||||
# Code-based challenges have no hints
|
||||
@ -64,26 +67,30 @@ def get_challenge_for_device(
|
||||
|
||||
|
||||
def get_webauthn_challenge_without_user(
|
||||
request: HttpRequest, stage: AuthenticatorValidateStage
|
||||
stage_view: "AuthenticatorValidateStageView", stage: AuthenticatorValidateStage
|
||||
) -> dict:
|
||||
"""Same as `get_webauthn_challenge`, but allows any client device. We can then later check
|
||||
who the device belongs to."""
|
||||
request.session.pop(SESSION_KEY_WEBAUTHN_CHALLENGE, None)
|
||||
stage_view.executor.plan.context.pop(PLAN_CONTEXT_WEBAUTHN_CHALLENGE, None)
|
||||
authentication_options = generate_authentication_options(
|
||||
rp_id=get_rp_id(request),
|
||||
rp_id=get_rp_id(stage_view.request),
|
||||
allow_credentials=[],
|
||||
user_verification=UserVerificationRequirement(stage.webauthn_user_verification),
|
||||
)
|
||||
request.session[SESSION_KEY_WEBAUTHN_CHALLENGE] = authentication_options.challenge
|
||||
stage_view.executor.plan.context[PLAN_CONTEXT_WEBAUTHN_CHALLENGE] = (
|
||||
authentication_options.challenge
|
||||
)
|
||||
|
||||
return loads(options_to_json(authentication_options))
|
||||
|
||||
|
||||
def get_webauthn_challenge(
|
||||
request: HttpRequest, stage: AuthenticatorValidateStage, device: WebAuthnDevice | None = None
|
||||
stage_view: "AuthenticatorValidateStageView",
|
||||
stage: AuthenticatorValidateStage,
|
||||
device: WebAuthnDevice | None = None,
|
||||
) -> dict:
|
||||
"""Send the client a challenge that we'll check later"""
|
||||
request.session.pop(SESSION_KEY_WEBAUTHN_CHALLENGE, None)
|
||||
stage_view.executor.plan.context.pop(PLAN_CONTEXT_WEBAUTHN_CHALLENGE, None)
|
||||
|
||||
allowed_credentials = []
|
||||
|
||||
@ -94,12 +101,14 @@ def get_webauthn_challenge(
|
||||
allowed_credentials.append(user_device.descriptor)
|
||||
|
||||
authentication_options = generate_authentication_options(
|
||||
rp_id=get_rp_id(request),
|
||||
rp_id=get_rp_id(stage_view.request),
|
||||
allow_credentials=allowed_credentials,
|
||||
user_verification=UserVerificationRequirement(stage.webauthn_user_verification),
|
||||
)
|
||||
|
||||
request.session[SESSION_KEY_WEBAUTHN_CHALLENGE] = authentication_options.challenge
|
||||
stage_view.executor.plan.context[PLAN_CONTEXT_WEBAUTHN_CHALLENGE] = (
|
||||
authentication_options.challenge
|
||||
)
|
||||
|
||||
return loads(options_to_json(authentication_options))
|
||||
|
||||
@ -146,7 +155,7 @@ def validate_challenge_code(code: str, stage_view: StageView, user: User) -> Dev
|
||||
def validate_challenge_webauthn(data: dict, stage_view: StageView, user: User) -> Device:
|
||||
"""Validate WebAuthn Challenge"""
|
||||
request = stage_view.request
|
||||
challenge = request.session.get(SESSION_KEY_WEBAUTHN_CHALLENGE)
|
||||
challenge = stage_view.executor.plan.context.get(PLAN_CONTEXT_WEBAUTHN_CHALLENGE)
|
||||
stage: AuthenticatorValidateStage = stage_view.executor.current_stage
|
||||
try:
|
||||
credential = parse_authentication_credential_json(data)
|
||||
|
@ -224,7 +224,7 @@ class AuthenticatorValidateStageView(ChallengeStageView):
|
||||
data={
|
||||
"device_class": device_class,
|
||||
"device_uid": device.pk,
|
||||
"challenge": get_challenge_for_device(self.request, stage, device),
|
||||
"challenge": get_challenge_for_device(self, stage, device),
|
||||
"last_used": device.last_used,
|
||||
}
|
||||
)
|
||||
@ -243,7 +243,7 @@ class AuthenticatorValidateStageView(ChallengeStageView):
|
||||
"device_class": DeviceClasses.WEBAUTHN,
|
||||
"device_uid": -1,
|
||||
"challenge": get_webauthn_challenge_without_user(
|
||||
self.request,
|
||||
self,
|
||||
self.executor.current_stage,
|
||||
),
|
||||
"last_used": None,
|
||||
|
@ -31,7 +31,7 @@ from authentik.stages.authenticator_webauthn.models import (
|
||||
WebAuthnDevice,
|
||||
WebAuthnDeviceType,
|
||||
)
|
||||
from authentik.stages.authenticator_webauthn.stage import SESSION_KEY_WEBAUTHN_CHALLENGE
|
||||
from authentik.stages.authenticator_webauthn.stage import PLAN_CONTEXT_WEBAUTHN_CHALLENGE
|
||||
from authentik.stages.authenticator_webauthn.tasks import webauthn_mds_import
|
||||
from authentik.stages.identification.models import IdentificationStage, UserFields
|
||||
from authentik.stages.user_login.models import UserLoginStage
|
||||
@ -103,7 +103,11 @@ class AuthenticatorValidateStageWebAuthnTests(FlowTestCase):
|
||||
device_classes=[DeviceClasses.WEBAUTHN],
|
||||
webauthn_user_verification=UserVerification.PREFERRED,
|
||||
)
|
||||
challenge = get_challenge_for_device(request, stage, webauthn_device)
|
||||
plan = FlowPlan("")
|
||||
stage_view = AuthenticatorValidateStageView(
|
||||
FlowExecutorView(flow=None, current_stage=stage, plan=plan), request=request
|
||||
)
|
||||
challenge = get_challenge_for_device(stage_view, stage, webauthn_device)
|
||||
del challenge["challenge"]
|
||||
self.assertEqual(
|
||||
challenge,
|
||||
@ -122,7 +126,9 @@ class AuthenticatorValidateStageWebAuthnTests(FlowTestCase):
|
||||
|
||||
with self.assertRaises(ValidationError):
|
||||
validate_challenge_webauthn(
|
||||
{}, StageView(FlowExecutorView(current_stage=stage), request=request), self.user
|
||||
{},
|
||||
StageView(FlowExecutorView(current_stage=stage, plan=plan), request=request),
|
||||
self.user,
|
||||
)
|
||||
|
||||
def test_device_challenge_webauthn_restricted(self):
|
||||
@ -193,22 +199,35 @@ class AuthenticatorValidateStageWebAuthnTests(FlowTestCase):
|
||||
sign_count=0,
|
||||
rp_id=generate_id(),
|
||||
)
|
||||
challenge = get_challenge_for_device(request, stage, webauthn_device)
|
||||
webauthn_challenge = request.session[SESSION_KEY_WEBAUTHN_CHALLENGE]
|
||||
plan = FlowPlan("")
|
||||
plan.context[PLAN_CONTEXT_WEBAUTHN_CHALLENGE] = base64url_to_bytes(
|
||||
"g98I51mQvZXo5lxLfhrD2zfolhZbLRyCgqkkYap1jwSaJ13BguoJWCF9_Lg3AgO4Wh-Bqa556JE20oKsYbl6RA"
|
||||
)
|
||||
stage_view = AuthenticatorValidateStageView(
|
||||
FlowExecutorView(flow=None, current_stage=stage, plan=plan), request=request
|
||||
)
|
||||
challenge = get_challenge_for_device(stage_view, stage, webauthn_device)
|
||||
self.assertEqual(
|
||||
challenge,
|
||||
{
|
||||
"allowCredentials": [
|
||||
{
|
||||
"id": "QKZ97ASJAOIDyipAs6mKUxDUZgDrWrbAsUb5leL7-oU",
|
||||
"type": "public-key",
|
||||
}
|
||||
],
|
||||
"challenge": bytes_to_base64url(webauthn_challenge),
|
||||
"rpId": "testserver",
|
||||
"timeout": 60000,
|
||||
"userVerification": "preferred",
|
||||
},
|
||||
challenge["allowCredentials"],
|
||||
[
|
||||
{
|
||||
"id": "QKZ97ASJAOIDyipAs6mKUxDUZgDrWrbAsUb5leL7-oU",
|
||||
"type": "public-key",
|
||||
}
|
||||
],
|
||||
)
|
||||
self.assertIsNotNone(challenge["challenge"])
|
||||
self.assertEqual(
|
||||
challenge["rpId"],
|
||||
"testserver",
|
||||
)
|
||||
self.assertEqual(
|
||||
challenge["timeout"],
|
||||
60000,
|
||||
)
|
||||
self.assertEqual(
|
||||
challenge["userVerification"],
|
||||
"preferred",
|
||||
)
|
||||
|
||||
def test_get_challenge_userless(self):
|
||||
@ -228,18 +247,16 @@ class AuthenticatorValidateStageWebAuthnTests(FlowTestCase):
|
||||
sign_count=0,
|
||||
rp_id=generate_id(),
|
||||
)
|
||||
challenge = get_webauthn_challenge_without_user(request, stage)
|
||||
webauthn_challenge = request.session[SESSION_KEY_WEBAUTHN_CHALLENGE]
|
||||
self.assertEqual(
|
||||
challenge,
|
||||
{
|
||||
"allowCredentials": [],
|
||||
"challenge": bytes_to_base64url(webauthn_challenge),
|
||||
"rpId": "testserver",
|
||||
"timeout": 60000,
|
||||
"userVerification": "preferred",
|
||||
},
|
||||
plan = FlowPlan("")
|
||||
stage_view = AuthenticatorValidateStageView(
|
||||
FlowExecutorView(flow=None, current_stage=stage, plan=plan), request=request
|
||||
)
|
||||
challenge = get_webauthn_challenge_without_user(stage_view, stage)
|
||||
self.assertEqual(challenge["allowCredentials"], [])
|
||||
self.assertIsNotNone(challenge["challenge"])
|
||||
self.assertEqual(challenge["rpId"], "testserver")
|
||||
self.assertEqual(challenge["timeout"], 60000)
|
||||
self.assertEqual(challenge["userVerification"], "preferred")
|
||||
|
||||
def test_validate_challenge_unrestricted(self):
|
||||
"""Test webauthn authentication (unrestricted webauthn device)"""
|
||||
@ -275,10 +292,10 @@ class AuthenticatorValidateStageWebAuthnTests(FlowTestCase):
|
||||
"last_used": None,
|
||||
}
|
||||
]
|
||||
session[SESSION_KEY_PLAN] = plan
|
||||
session[SESSION_KEY_WEBAUTHN_CHALLENGE] = base64url_to_bytes(
|
||||
plan.context[PLAN_CONTEXT_WEBAUTHN_CHALLENGE] = base64url_to_bytes(
|
||||
"aCC6ak_DP45xMH1qyxzUM5iC2xc4QthQb09v7m4qDBmY8FvWvhxFzSuFlDYQmclrh5fWS5q0TPxgJGF4vimcFQ"
|
||||
)
|
||||
session[SESSION_KEY_PLAN] = plan
|
||||
session.save()
|
||||
|
||||
response = self.client.post(
|
||||
@ -352,10 +369,10 @@ class AuthenticatorValidateStageWebAuthnTests(FlowTestCase):
|
||||
"last_used": None,
|
||||
}
|
||||
]
|
||||
session[SESSION_KEY_PLAN] = plan
|
||||
session[SESSION_KEY_WEBAUTHN_CHALLENGE] = base64url_to_bytes(
|
||||
plan.context[PLAN_CONTEXT_WEBAUTHN_CHALLENGE] = base64url_to_bytes(
|
||||
"aCC6ak_DP45xMH1qyxzUM5iC2xc4QthQb09v7m4qDBmY8FvWvhxFzSuFlDYQmclrh5fWS5q0TPxgJGF4vimcFQ"
|
||||
)
|
||||
session[SESSION_KEY_PLAN] = plan
|
||||
session.save()
|
||||
|
||||
response = self.client.post(
|
||||
@ -433,10 +450,10 @@ class AuthenticatorValidateStageWebAuthnTests(FlowTestCase):
|
||||
"last_used": None,
|
||||
}
|
||||
]
|
||||
session[SESSION_KEY_PLAN] = plan
|
||||
session[SESSION_KEY_WEBAUTHN_CHALLENGE] = base64url_to_bytes(
|
||||
plan.context[PLAN_CONTEXT_WEBAUTHN_CHALLENGE] = base64url_to_bytes(
|
||||
"g98I51mQvZXo5lxLfhrD2zfolhZbLRyCgqkkYap1jwSaJ13BguoJWCF9_Lg3AgO4Wh-Bqa556JE20oKsYbl6RA"
|
||||
)
|
||||
session[SESSION_KEY_PLAN] = plan
|
||||
session.save()
|
||||
|
||||
response = self.client.post(
|
||||
@ -496,17 +513,14 @@ class AuthenticatorValidateStageWebAuthnTests(FlowTestCase):
|
||||
not_configured_action=NotConfiguredAction.CONFIGURE,
|
||||
device_classes=[DeviceClasses.WEBAUTHN],
|
||||
)
|
||||
stage_view = AuthenticatorValidateStageView(
|
||||
FlowExecutorView(flow=flow, current_stage=stage), request=request
|
||||
)
|
||||
request = get_request("/")
|
||||
request.session[SESSION_KEY_WEBAUTHN_CHALLENGE] = base64url_to_bytes(
|
||||
plan = FlowPlan(flow.pk.hex)
|
||||
plan.context[PLAN_CONTEXT_WEBAUTHN_CHALLENGE] = base64url_to_bytes(
|
||||
"g98I51mQvZXo5lxLfhrD2zfolhZbLRyCgqkkYap1jwSaJ13BguoJWCF9_Lg3AgO4Wh-Bqa556JE20oKsYbl6RA"
|
||||
)
|
||||
request.session.save()
|
||||
request = get_request("/")
|
||||
|
||||
stage_view = AuthenticatorValidateStageView(
|
||||
FlowExecutorView(flow=flow, current_stage=stage), request=request
|
||||
FlowExecutorView(flow=flow, current_stage=stage, plan=plan), request=request
|
||||
)
|
||||
request.META["SERVER_NAME"] = "localhost"
|
||||
request.META["SERVER_PORT"] = "9000"
|
||||
|
@ -25,6 +25,7 @@ class AuthenticatorWebAuthnStageSerializer(StageSerializer):
|
||||
"resident_key_requirement",
|
||||
"device_type_restrictions",
|
||||
"device_type_restrictions_obj",
|
||||
"max_attempts",
|
||||
]
|
||||
|
||||
|
||||
|
@ -0,0 +1,21 @@
|
||||
# Generated by Django 5.1.11 on 2025-06-13 22:41
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
(
|
||||
"authentik_stages_authenticator_webauthn",
|
||||
"0012_webauthndevice_created_webauthndevice_last_updated_and_more",
|
||||
),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="authenticatorwebauthnstage",
|
||||
name="max_attempts",
|
||||
field=models.PositiveIntegerField(default=0),
|
||||
),
|
||||
]
|
@ -84,6 +84,8 @@ class AuthenticatorWebAuthnStage(ConfigurableStage, FriendlyNamedStage, Stage):
|
||||
|
||||
device_type_restrictions = models.ManyToManyField("WebAuthnDeviceType", blank=True)
|
||||
|
||||
max_attempts = models.PositiveIntegerField(default=0)
|
||||
|
||||
@property
|
||||
def serializer(self) -> type[BaseSerializer]:
|
||||
from authentik.stages.authenticator_webauthn.api.stages import (
|
||||
|
@ -5,12 +5,13 @@ from uuid import UUID
|
||||
|
||||
from django.http import HttpRequest, HttpResponse
|
||||
from django.http.request import QueryDict
|
||||
from django.utils.translation import gettext as __
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from rest_framework.fields import CharField
|
||||
from rest_framework.serializers import ValidationError
|
||||
from webauthn import options_to_json
|
||||
from webauthn.helpers.bytes_to_base64url import bytes_to_base64url
|
||||
from webauthn.helpers.exceptions import InvalidRegistrationResponse
|
||||
from webauthn.helpers.exceptions import WebAuthnException
|
||||
from webauthn.helpers.structs import (
|
||||
AttestationConveyancePreference,
|
||||
AuthenticatorAttachment,
|
||||
@ -41,7 +42,8 @@ from authentik.stages.authenticator_webauthn.models import (
|
||||
)
|
||||
from authentik.stages.authenticator_webauthn.utils import get_origin, get_rp_id
|
||||
|
||||
SESSION_KEY_WEBAUTHN_CHALLENGE = "authentik/stages/authenticator_webauthn/challenge"
|
||||
PLAN_CONTEXT_WEBAUTHN_CHALLENGE = "goauthentik.io/stages/authenticator_webauthn/challenge"
|
||||
PLAN_CONTEXT_WEBAUTHN_ATTEMPT = "goauthentik.io/stages/authenticator_webauthn/attempt"
|
||||
|
||||
|
||||
class AuthenticatorWebAuthnChallenge(WithUserInfoChallenge):
|
||||
@ -62,7 +64,7 @@ class AuthenticatorWebAuthnChallengeResponse(ChallengeResponse):
|
||||
|
||||
def validate_response(self, response: dict) -> dict:
|
||||
"""Validate webauthn challenge response"""
|
||||
challenge = self.request.session[SESSION_KEY_WEBAUTHN_CHALLENGE]
|
||||
challenge = self.stage.executor.plan.context[PLAN_CONTEXT_WEBAUTHN_CHALLENGE]
|
||||
|
||||
try:
|
||||
registration: VerifiedRegistration = verify_registration_response(
|
||||
@ -71,7 +73,7 @@ class AuthenticatorWebAuthnChallengeResponse(ChallengeResponse):
|
||||
expected_rp_id=get_rp_id(self.request),
|
||||
expected_origin=get_origin(self.request),
|
||||
)
|
||||
except InvalidRegistrationResponse as exc:
|
||||
except WebAuthnException as exc:
|
||||
self.stage.logger.warning("registration failed", exc=exc)
|
||||
raise ValidationError(f"Registration failed. Error: {exc}") from None
|
||||
|
||||
@ -114,9 +116,10 @@ class AuthenticatorWebAuthnStageView(ChallengeStageView):
|
||||
response_class = AuthenticatorWebAuthnChallengeResponse
|
||||
|
||||
def get_challenge(self, *args, **kwargs) -> Challenge:
|
||||
# clear session variables prior to starting a new registration
|
||||
self.request.session.pop(SESSION_KEY_WEBAUTHN_CHALLENGE, None)
|
||||
stage: AuthenticatorWebAuthnStage = self.executor.current_stage
|
||||
self.executor.plan.context.setdefault(PLAN_CONTEXT_WEBAUTHN_ATTEMPT, 0)
|
||||
# clear flow variables prior to starting a new registration
|
||||
self.executor.plan.context.pop(PLAN_CONTEXT_WEBAUTHN_CHALLENGE, None)
|
||||
user = self.get_pending_user()
|
||||
|
||||
# library accepts none so we store null in the database, but if there is a value
|
||||
@ -139,8 +142,7 @@ class AuthenticatorWebAuthnStageView(ChallengeStageView):
|
||||
attestation=AttestationConveyancePreference.DIRECT,
|
||||
)
|
||||
|
||||
self.request.session[SESSION_KEY_WEBAUTHN_CHALLENGE] = registration_options.challenge
|
||||
self.request.session.save()
|
||||
self.executor.plan.context[PLAN_CONTEXT_WEBAUTHN_CHALLENGE] = registration_options.challenge
|
||||
return AuthenticatorWebAuthnChallenge(
|
||||
data={
|
||||
"registration": loads(options_to_json(registration_options)),
|
||||
@ -153,6 +155,24 @@ class AuthenticatorWebAuthnStageView(ChallengeStageView):
|
||||
response.user = self.get_pending_user()
|
||||
return response
|
||||
|
||||
def challenge_invalid(self, response):
|
||||
stage: AuthenticatorWebAuthnStage = self.executor.current_stage
|
||||
self.executor.plan.context.setdefault(PLAN_CONTEXT_WEBAUTHN_ATTEMPT, 0)
|
||||
self.executor.plan.context[PLAN_CONTEXT_WEBAUTHN_ATTEMPT] += 1
|
||||
if (
|
||||
stage.max_attempts > 0
|
||||
and self.executor.plan.context[PLAN_CONTEXT_WEBAUTHN_ATTEMPT] >= stage.max_attempts
|
||||
):
|
||||
return self.executor.stage_invalid(
|
||||
__(
|
||||
"Exceeded maximum attempts. "
|
||||
"Contact your {brand} administrator for help.".format(
|
||||
brand=self.request.brand.branding_title
|
||||
)
|
||||
)
|
||||
)
|
||||
return super().challenge_invalid(response)
|
||||
|
||||
def challenge_valid(self, response: ChallengeResponse) -> HttpResponse:
|
||||
# Webauthn Challenge has already been validated
|
||||
webauthn_credential: VerifiedRegistration = response.validated_data["response"]
|
||||
@ -179,6 +199,3 @@ class AuthenticatorWebAuthnStageView(ChallengeStageView):
|
||||
else:
|
||||
return self.executor.stage_invalid("Device with Credential ID already exists.")
|
||||
return self.executor.stage_ok()
|
||||
|
||||
def cleanup(self):
|
||||
self.request.session.pop(SESSION_KEY_WEBAUTHN_CHALLENGE, None)
|
||||
|
@ -18,7 +18,7 @@ from authentik.stages.authenticator_webauthn.models import (
|
||||
WebAuthnDevice,
|
||||
WebAuthnDeviceType,
|
||||
)
|
||||
from authentik.stages.authenticator_webauthn.stage import SESSION_KEY_WEBAUTHN_CHALLENGE
|
||||
from authentik.stages.authenticator_webauthn.stage import PLAN_CONTEXT_WEBAUTHN_CHALLENGE
|
||||
from authentik.stages.authenticator_webauthn.tasks import webauthn_mds_import
|
||||
|
||||
|
||||
@ -57,6 +57,9 @@ class TestAuthenticatorWebAuthnStage(FlowTestCase):
|
||||
response = self.client.get(
|
||||
reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}),
|
||||
)
|
||||
|
||||
plan: FlowPlan = self.client.session[SESSION_KEY_PLAN]
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
session = self.client.session
|
||||
self.assertStageResponse(
|
||||
@ -70,7 +73,7 @@ class TestAuthenticatorWebAuthnStage(FlowTestCase):
|
||||
"name": self.user.username,
|
||||
"displayName": self.user.name,
|
||||
},
|
||||
"challenge": bytes_to_base64url(session[SESSION_KEY_WEBAUTHN_CHALLENGE]),
|
||||
"challenge": bytes_to_base64url(plan.context[PLAN_CONTEXT_WEBAUTHN_CHALLENGE]),
|
||||
"pubKeyCredParams": [
|
||||
{"type": "public-key", "alg": -7},
|
||||
{"type": "public-key", "alg": -8},
|
||||
@ -97,11 +100,11 @@ class TestAuthenticatorWebAuthnStage(FlowTestCase):
|
||||
"""Test registration"""
|
||||
plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
|
||||
plan.context[PLAN_CONTEXT_PENDING_USER] = self.user
|
||||
session = self.client.session
|
||||
session[SESSION_KEY_PLAN] = plan
|
||||
session[SESSION_KEY_WEBAUTHN_CHALLENGE] = b64decode(
|
||||
plan.context[PLAN_CONTEXT_WEBAUTHN_CHALLENGE] = b64decode(
|
||||
b"03Xodi54gKsfnP5I9VFfhaGXVVE2NUyZpBBXns/JI+x6V9RY2Tw2QmxRJkhh7174EkRazUntIwjMVY9bFG60Lw=="
|
||||
)
|
||||
session = self.client.session
|
||||
session[SESSION_KEY_PLAN] = plan
|
||||
session.save()
|
||||
response = self.client.post(
|
||||
reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}),
|
||||
@ -146,11 +149,11 @@ class TestAuthenticatorWebAuthnStage(FlowTestCase):
|
||||
|
||||
plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
|
||||
plan.context[PLAN_CONTEXT_PENDING_USER] = self.user
|
||||
session = self.client.session
|
||||
session[SESSION_KEY_PLAN] = plan
|
||||
session[SESSION_KEY_WEBAUTHN_CHALLENGE] = b64decode(
|
||||
plan.context[PLAN_CONTEXT_WEBAUTHN_CHALLENGE] = b64decode(
|
||||
b"03Xodi54gKsfnP5I9VFfhaGXVVE2NUyZpBBXns/JI+x6V9RY2Tw2QmxRJkhh7174EkRazUntIwjMVY9bFG60Lw=="
|
||||
)
|
||||
session = self.client.session
|
||||
session[SESSION_KEY_PLAN] = plan
|
||||
session.save()
|
||||
response = self.client.post(
|
||||
reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}),
|
||||
@ -209,11 +212,11 @@ class TestAuthenticatorWebAuthnStage(FlowTestCase):
|
||||
|
||||
plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
|
||||
plan.context[PLAN_CONTEXT_PENDING_USER] = self.user
|
||||
session = self.client.session
|
||||
session[SESSION_KEY_PLAN] = plan
|
||||
session[SESSION_KEY_WEBAUTHN_CHALLENGE] = b64decode(
|
||||
plan.context[PLAN_CONTEXT_WEBAUTHN_CHALLENGE] = b64decode(
|
||||
b"03Xodi54gKsfnP5I9VFfhaGXVVE2NUyZpBBXns/JI+x6V9RY2Tw2QmxRJkhh7174EkRazUntIwjMVY9bFG60Lw=="
|
||||
)
|
||||
session = self.client.session
|
||||
session[SESSION_KEY_PLAN] = plan
|
||||
session.save()
|
||||
response = self.client.post(
|
||||
reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}),
|
||||
@ -259,11 +262,11 @@ class TestAuthenticatorWebAuthnStage(FlowTestCase):
|
||||
|
||||
plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
|
||||
plan.context[PLAN_CONTEXT_PENDING_USER] = self.user
|
||||
session = self.client.session
|
||||
session[SESSION_KEY_PLAN] = plan
|
||||
session[SESSION_KEY_WEBAUTHN_CHALLENGE] = b64decode(
|
||||
plan.context[PLAN_CONTEXT_WEBAUTHN_CHALLENGE] = b64decode(
|
||||
b"03Xodi54gKsfnP5I9VFfhaGXVVE2NUyZpBBXns/JI+x6V9RY2Tw2QmxRJkhh7174EkRazUntIwjMVY9bFG60Lw=="
|
||||
)
|
||||
session = self.client.session
|
||||
session[SESSION_KEY_PLAN] = plan
|
||||
session.save()
|
||||
response = self.client.post(
|
||||
reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}),
|
||||
@ -298,3 +301,109 @@ class TestAuthenticatorWebAuthnStage(FlowTestCase):
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertStageRedirects(response, reverse("authentik_core:root-redirect"))
|
||||
self.assertTrue(WebAuthnDevice.objects.filter(user=self.user).exists())
|
||||
|
||||
def test_register_max_retries(self):
|
||||
"""Test registration (exceeding max retries)"""
|
||||
self.stage.max_attempts = 2
|
||||
self.stage.save()
|
||||
|
||||
plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
|
||||
plan.context[PLAN_CONTEXT_PENDING_USER] = self.user
|
||||
plan.context[PLAN_CONTEXT_WEBAUTHN_CHALLENGE] = b64decode(
|
||||
b"03Xodi54gKsfnP5I9VFfhaGXVVE2NUyZpBBXns/JI+x6V9RY2Tw2QmxRJkhh7174EkRazUntIwjMVY9bFG60Lw=="
|
||||
)
|
||||
session = self.client.session
|
||||
session[SESSION_KEY_PLAN] = plan
|
||||
session.save()
|
||||
|
||||
# first failed request
|
||||
response = self.client.post(
|
||||
reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}),
|
||||
data={
|
||||
"component": "ak-stage-authenticator-webauthn",
|
||||
"response": {
|
||||
"id": "kqnmrVLnDG-OwsSNHkihYZaNz5s",
|
||||
"rawId": "kqnmrVLnDG-OwsSNHkihYZaNz5s",
|
||||
"type": "public-key",
|
||||
"registrationClientExtensions": "{}",
|
||||
"response": {
|
||||
"clientDataJSON": (
|
||||
"eyJ0eXBlIjoid2ViYXV0aG4uY3JlYXRlIiwiY2hhbGxlbmd"
|
||||
"lIjoiMDNYb2RpNTRnS3NmblA1STlWRmZoYUdYVlZFMk5VeV"
|
||||
"pwQkJYbnNfSkkteDZWOVJZMlR3MlFteFJKa2hoNzE3NEVrU"
|
||||
"mF6VW50SXdqTVZZOWJGRzYwTHciLCJvcmlnaW4iOiJodHRw"
|
||||
"Oi8vbG9jYWxob3N0OjkwMDAiLCJjcm9zc09yaWdpbiI6ZmF"
|
||||
),
|
||||
"attestationObject": (
|
||||
"o2NmbXRkbm9uZWdhdHRTdG10oGhhdXRoRGF0YViYSZYN5Yg"
|
||||
"OjGh0NBcPZHZgW4_krrmihjLHmVzzuoMdl2NdAAAAAPv8MA"
|
||||
"cVTk7MjAtuAgVX170AFJKp5q1S5wxvjsLEjR5IoWGWjc-bp"
|
||||
"QECAyYgASFYIKtcZHPumH37XHs0IM1v3pUBRIqHVV_SE-Lq"
|
||||
"2zpJAOVXIlgg74Fg_WdB0kuLYqCKbxogkEPaVtR_iR3IyQFIJAXBzds"
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
SERVER_NAME="localhost",
|
||||
SERVER_PORT="9000",
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertStageResponse(
|
||||
response,
|
||||
flow=self.flow,
|
||||
component="ak-stage-authenticator-webauthn",
|
||||
response_errors={
|
||||
"response": [
|
||||
{
|
||||
"string": (
|
||||
"Registration failed. Error: Unable to decode "
|
||||
"client_data_json bytes as JSON"
|
||||
),
|
||||
"code": "invalid",
|
||||
}
|
||||
]
|
||||
},
|
||||
)
|
||||
self.assertFalse(WebAuthnDevice.objects.filter(user=self.user).exists())
|
||||
|
||||
# Second failed request
|
||||
response = self.client.post(
|
||||
reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}),
|
||||
data={
|
||||
"component": "ak-stage-authenticator-webauthn",
|
||||
"response": {
|
||||
"id": "kqnmrVLnDG-OwsSNHkihYZaNz5s",
|
||||
"rawId": "kqnmrVLnDG-OwsSNHkihYZaNz5s",
|
||||
"type": "public-key",
|
||||
"registrationClientExtensions": "{}",
|
||||
"response": {
|
||||
"clientDataJSON": (
|
||||
"eyJ0eXBlIjoid2ViYXV0aG4uY3JlYXRlIiwiY2hhbGxlbmd"
|
||||
"lIjoiMDNYb2RpNTRnS3NmblA1STlWRmZoYUdYVlZFMk5VeV"
|
||||
"pwQkJYbnNfSkkteDZWOVJZMlR3MlFteFJKa2hoNzE3NEVrU"
|
||||
"mF6VW50SXdqTVZZOWJGRzYwTHciLCJvcmlnaW4iOiJodHRw"
|
||||
"Oi8vbG9jYWxob3N0OjkwMDAiLCJjcm9zc09yaWdpbiI6ZmF"
|
||||
),
|
||||
"attestationObject": (
|
||||
"o2NmbXRkbm9uZWdhdHRTdG10oGhhdXRoRGF0YViYSZYN5Yg"
|
||||
"OjGh0NBcPZHZgW4_krrmihjLHmVzzuoMdl2NdAAAAAPv8MA"
|
||||
"cVTk7MjAtuAgVX170AFJKp5q1S5wxvjsLEjR5IoWGWjc-bp"
|
||||
"QECAyYgASFYIKtcZHPumH37XHs0IM1v3pUBRIqHVV_SE-Lq"
|
||||
"2zpJAOVXIlgg74Fg_WdB0kuLYqCKbxogkEPaVtR_iR3IyQFIJAXBzds"
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
SERVER_NAME="localhost",
|
||||
SERVER_PORT="9000",
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertStageResponse(
|
||||
response,
|
||||
flow=self.flow,
|
||||
component="ak-stage-access-denied",
|
||||
error_message=(
|
||||
"Exceeded maximum attempts. Contact your authentik administrator for help."
|
||||
),
|
||||
)
|
||||
self.assertFalse(WebAuthnDevice.objects.filter(user=self.user).exists())
|
||||
|
@ -21,6 +21,7 @@ from authentik.flows.models import FlowDesignation, FlowToken
|
||||
from authentik.flows.planner import PLAN_CONTEXT_IS_RESTORED, PLAN_CONTEXT_PENDING_USER
|
||||
from authentik.flows.stage import ChallengeStageView
|
||||
from authentik.flows.views.executor import QS_KEY_TOKEN, QS_QUERY
|
||||
from authentik.lib.utils.errors import exception_to_string
|
||||
from authentik.lib.utils.time import timedelta_from_string
|
||||
from authentik.stages.email.flow import pickle_flow_token_for_email
|
||||
from authentik.stages.email.models import EmailStage
|
||||
@ -128,8 +129,9 @@ class EmailStageView(ChallengeStageView):
|
||||
Event.new(
|
||||
EventAction.CONFIGURATION_ERROR,
|
||||
message=_("Exception occurred while rendering E-mail template"),
|
||||
error=exception_to_string(exc),
|
||||
template=current_stage.template,
|
||||
).with_exception(exc).from_http(self.request)
|
||||
).from_http(self.request)
|
||||
raise StageInvalidException from exc
|
||||
|
||||
def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
|
||||
|
@ -27,7 +27,6 @@
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
<td>
|
||||
{% endblock %}
|
||||
|
||||
{% block sub_content %}
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user