Compare commits
198 Commits
version/20
...
version/20
Author | SHA1 | Date | |
---|---|---|---|
72f85defb8 | |||
b46048e74f | |||
bf7dc5df78 | |||
f0d0abb66e | |||
fab6a8f8c9 | |||
61bf73d2f9 | |||
9219abf84b | |||
178bfe1d44 | |||
afb7f8be3e | |||
ba08060337 | |||
26243c05ed | |||
56375d7245 | |||
94f22cffba | |||
10b7d78825 | |||
7618c2e45f | |||
e13615c1ae | |||
06fb81410b | |||
5732fc0c2e | |||
59e54901fb | |||
0ef333f8ea | |||
12ef7e2fae | |||
397d6ff059 | |||
6469698261 | |||
3b733e98fa | |||
9e855d1f0e | |||
a2ee76b328 | |||
86bb2afd02 | |||
9b8c0e3924 | |||
d11ee46589 | |||
b6b820f6f1 | |||
e28f897cb1 | |||
964f095630 | |||
610012fcc3 | |||
3c970a135c | |||
24d7cebbe7 | |||
618527b51c | |||
4afcc240a3 | |||
26308ef62b | |||
36ed62142d | |||
6ae2fc9668 | |||
34f01d3731 | |||
67f3db1e03 | |||
36f92f01de | |||
f19c143e95 | |||
9559bc2e1e | |||
41d17dc543 | |||
885aeddbdc | |||
033e315035 | |||
4539954173 | |||
f54351fd57 | |||
b61655fe4f | |||
2a74f5e91f | |||
7ea3fd6482 | |||
c02e2c22ff | |||
d834ec4db9 | |||
f6a8b3d568 | |||
c4a7648ce3 | |||
c0144c9bc1 | |||
28ddeb124f | |||
dbc07f55f4 | |||
0d7c2d8269 | |||
879ea8ed62 | |||
54c76735e2 | |||
b6f5fed121 | |||
e08536af33 | |||
2c32e54746 | |||
9370d155f8 | |||
e47bbe63b8 | |||
972dce1462 | |||
7b44d8972f | |||
ba9cafecc5 | |||
e0f4f2c80a | |||
e715f1fbbb | |||
dde9d02008 | |||
a6eba37d5a | |||
2eb7c16a9a | |||
87fa50c492 | |||
9042664fcf | |||
e8a53041cc | |||
20e971f5ce | |||
6f2f4f4aa3 | |||
66e8748503 | |||
b5c8fa24a2 | |||
335e124c0a | |||
1faf3c66c7 | |||
7810063ca0 | |||
980320e24b | |||
118765ab30 | |||
5e60db8593 | |||
e81a065855 | |||
39d0893303 | |||
99ddbf553c | |||
799b509ac6 | |||
bc6b591dfb | |||
ef6a799533 | |||
26de143bf8 | |||
ad46b3f05c | |||
0462afe964 | |||
1100b98596 | |||
89ccdfcf6e | |||
a328d2d68a | |||
d5a94ea687 | |||
596ff529c4 | |||
612d1c76d4 | |||
886749dcb2 | |||
26f3275361 | |||
6441401d94 | |||
b7e4ad7234 | |||
9c82024fd5 | |||
685709decb | |||
6ab2afc8d4 | |||
23ad132f74 | |||
87164f5cdb | |||
d6056755b3 | |||
36229f4224 | |||
80f4fccd35 | |||
c6a14fa4f1 | |||
c6235e0f1e | |||
7c946c1cbe | |||
664d8646bb | |||
1aba27c84f | |||
008729700d | |||
9e1cedbece | |||
7503b32c74 | |||
383b6a38ba | |||
7d9eef37ed | |||
60d3da20f3 | |||
cd99b6e48f | |||
51c6a14786 | |||
75866406dc | |||
122055b38b | |||
e68e6cb666 | |||
b61d181ec7 | |||
c4e24c04f6 | |||
47e663f48c | |||
1f7178c3a8 | |||
cfa2edebcf | |||
175502b053 | |||
2c78053631 | |||
53c03f3635 | |||
6c72c97513 | |||
5748b29c03 | |||
87ee4635b2 | |||
9e82de33e6 | |||
8829f76183 | |||
f6165bac8f | |||
84bd6131a1 | |||
6d1de4bbd9 | |||
5a8fbc2f95 | |||
d2cfb76a7c | |||
f70be86ddc | |||
f5eb414d14 | |||
327d87355d | |||
b415e9b773 | |||
b203de7a26 | |||
ee65877956 | |||
c5097bfc5a | |||
febb6f57bd | |||
843cbd4674 | |||
a4a82bd041 | |||
ca2a59281a | |||
6f1721a728 | |||
99baf1a29e | |||
a68fa06ff9 | |||
9f431396c0 | |||
1ac2e924a2 | |||
0874574e5c | |||
069e9c015b | |||
8de4471322 | |||
c6ead3dc49 | |||
f749027143 | |||
153bd3aaf1 | |||
e19c4886fe | |||
1a57d453ba | |||
e5dfe7dafe | |||
bb190852a5 | |||
34a2d105d3 | |||
80601e16f9 | |||
ff6f9cc44f | |||
e0f60e09cf | |||
176aa606ca | |||
17364c3bd8 | |||
d842fc4958 | |||
19f5e6e07e | |||
acfa9c76d1 | |||
bff34cc5dc | |||
7f009f6d02 | |||
dfb9ae548c | |||
7d6b573f8b | |||
ade397fc24 | |||
d945d30cda | |||
c8c401e2c5 | |||
e4ca20bfc6 | |||
6347716815 | |||
859b6cc60e | |||
06a1a7f076 | |||
b6c120f555 | |||
6cc363bc5d |
@ -1,5 +1,5 @@
|
||||
[bumpversion]
|
||||
current_version = 2023.2.1
|
||||
current_version = 2023.3.0
|
||||
tag = True
|
||||
commit = True
|
||||
parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)
|
||||
|
3
.github/workflows/ci-main.yml
vendored
3
.github/workflows/ci-main.yml
vendored
@ -80,6 +80,7 @@ jobs:
|
||||
run: poetry run python -m lifecycle.migrate
|
||||
test-unittest:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Setup authentik env
|
||||
@ -94,6 +95,7 @@ jobs:
|
||||
flags: unit
|
||||
test-integration:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Setup authentik env
|
||||
@ -111,6 +113,7 @@ jobs:
|
||||
test-e2e:
|
||||
name: test-e2e (${{ matrix.job.name }})
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
|
2
.github/workflows/ghcr-retention.yml
vendored
2
.github/workflows/ghcr-retention.yml
vendored
@ -11,7 +11,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Delete 'dev' containers older than a week
|
||||
uses: snok/container-retention-policy@v1
|
||||
uses: snok/container-retention-policy@v2
|
||||
with:
|
||||
image-names: dev-server,dev-ldap,dev-proxy
|
||||
cut-off: One week ago UTC
|
||||
|
3
.gitignore
vendored
3
.gitignore
vendored
@ -200,3 +200,6 @@ media/
|
||||
.idea/
|
||||
/gen-*/
|
||||
data/
|
||||
|
||||
# Local Netlify folder
|
||||
.netlify
|
||||
|
20
.vscode/extensions.json
vendored
Normal file
20
.vscode/extensions.json
vendored
Normal file
@ -0,0 +1,20 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"EditorConfig.EditorConfig",
|
||||
"bashmish.es6-string-css",
|
||||
"bpruitt-goddard.mermaid-markdown-syntax-highlighting",
|
||||
"dbaeumer.vscode-eslint",
|
||||
"esbenp.prettier-vscode",
|
||||
"golang.go",
|
||||
"Gruntfuggly.todo-tree",
|
||||
"mechatroner.rainbow-csv",
|
||||
"ms-python.black-formatter",
|
||||
"ms-python.isort",
|
||||
"ms-python.pylint",
|
||||
"ms-python.python",
|
||||
"ms-python.vscode-pylance",
|
||||
"redhat.vscode-yaml",
|
||||
"Tobermory.es6-string-html",
|
||||
"unifiedjs.vscode-mdx"
|
||||
]
|
||||
}
|
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
@ -16,7 +16,8 @@
|
||||
"passwordless",
|
||||
"kubernetes",
|
||||
"sso",
|
||||
"slo"
|
||||
"slo",
|
||||
"scim",
|
||||
],
|
||||
"python.linting.pylintEnabled": true,
|
||||
"todo-tree.tree.showCountsInTree": true,
|
||||
|
@ -154,12 +154,19 @@ While the prerequisites above must be satisfied prior to having your pull reques
|
||||
|
||||
## Styleguides
|
||||
|
||||
### PR naming
|
||||
|
||||
- Use the format of `<package>: <verb> <description>`
|
||||
- See [here](#authentik-packages) for `package`
|
||||
- Example: `providers/saml2: fix parsing of requests`
|
||||
|
||||
### Git Commit Messages
|
||||
|
||||
- Use the format of `<package>: <verb> <description>`
|
||||
- See [here](#authentik-packages) for `package`
|
||||
- Example: `providers/saml2: fix parsing of requests`
|
||||
- Reference issues and pull requests liberally after the first line
|
||||
- Naming of commits within a PR does not need to adhere to the guidelines as we squash merge PRs
|
||||
|
||||
### Python Styleguide
|
||||
|
||||
|
@ -31,7 +31,7 @@ RUN pip install --no-cache-dir poetry && \
|
||||
poetry export -f requirements.txt --dev --output requirements-dev.txt
|
||||
|
||||
# Stage 4: Build go proxy
|
||||
FROM docker.io/golang:1.20.0-bullseye AS go-builder
|
||||
FROM docker.io/golang:1.20.2-bullseye AS go-builder
|
||||
|
||||
WORKDIR /work
|
||||
|
||||
@ -96,7 +96,7 @@ RUN apt-get update && \
|
||||
|
||||
COPY ./authentik/ /authentik
|
||||
COPY ./pyproject.toml /
|
||||
COPY ./xml /xml
|
||||
COPY ./schemas /schemas
|
||||
COPY ./locale /locale
|
||||
COPY ./tests /tests
|
||||
COPY ./manage.py /
|
||||
|
@ -8,6 +8,7 @@ Authentik takes security very seriously. We follow the rules of [responsible dis
|
||||
| --------- | ------------------ |
|
||||
| 2022.12.x | :white_check_mark: |
|
||||
| 2023.1.x | :white_check_mark: |
|
||||
| 2023.2.x | :white_check_mark: |
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
|
@ -2,7 +2,7 @@
|
||||
from os import environ
|
||||
from typing import Optional
|
||||
|
||||
__version__ = "2023.2.1"
|
||||
__version__ = "2023.3.0"
|
||||
ENV_GIT_HASH_KEY = "GIT_BUILD_HASH"
|
||||
|
||||
|
||||
|
@ -9,6 +9,7 @@ from authentik.blueprints.tests import reconcile_app
|
||||
from authentik.core.models import Group, User
|
||||
from authentik.core.tasks import clean_expired_models
|
||||
from authentik.events.monitored_tasks import TaskResultStatus
|
||||
from authentik.lib.generators import generate_id
|
||||
|
||||
|
||||
class TestAdminAPI(TestCase):
|
||||
@ -16,8 +17,8 @@ class TestAdminAPI(TestCase):
|
||||
|
||||
def setUp(self) -> None:
|
||||
super().setUp()
|
||||
self.user = User.objects.create(username="test-user")
|
||||
self.group = Group.objects.create(name="superusers", is_superuser=True)
|
||||
self.user = User.objects.create(username=generate_id())
|
||||
self.group = Group.objects.create(name=generate_id(), is_superuser=True)
|
||||
self.group.users.add(self.user)
|
||||
self.group.save()
|
||||
self.client.force_login(self.user)
|
||||
|
@ -4,6 +4,7 @@ from base64 import b64encode
|
||||
|
||||
from django.conf import settings
|
||||
from django.test import TestCase
|
||||
from django.utils import timezone
|
||||
from rest_framework.exceptions import AuthenticationFailed
|
||||
|
||||
from authentik.api.authentication import bearer_auth
|
||||
@ -68,6 +69,7 @@ class TestAPIAuth(TestCase):
|
||||
user=create_test_admin_user(),
|
||||
provider=provider,
|
||||
token=generate_id(),
|
||||
auth_time=timezone.now(),
|
||||
_scope=SCOPE_AUTHENTIK_API,
|
||||
_id_token=json.dumps({}),
|
||||
)
|
||||
@ -82,6 +84,7 @@ class TestAPIAuth(TestCase):
|
||||
user=create_test_admin_user(),
|
||||
provider=provider,
|
||||
token=generate_id(),
|
||||
auth_time=timezone.now(),
|
||||
_scope="",
|
||||
_id_token=json.dumps({}),
|
||||
)
|
||||
|
@ -4,6 +4,7 @@ from guardian.shortcuts import assign_perm
|
||||
from rest_framework.test import APITestCase
|
||||
|
||||
from authentik.core.models import Application, User
|
||||
from authentik.lib.generators import generate_id
|
||||
|
||||
|
||||
class TestAPIDecorators(APITestCase):
|
||||
@ -16,7 +17,7 @@ class TestAPIDecorators(APITestCase):
|
||||
def test_obj_perm_denied(self):
|
||||
"""Test object perm denied"""
|
||||
self.client.force_login(self.user)
|
||||
app = Application.objects.create(name="denied", slug="denied")
|
||||
app = Application.objects.create(name=generate_id(), slug=generate_id())
|
||||
response = self.client.get(
|
||||
reverse("authentik_api:application-metrics", kwargs={"slug": app.slug})
|
||||
)
|
||||
@ -25,7 +26,7 @@ class TestAPIDecorators(APITestCase):
|
||||
def test_other_perm_denied(self):
|
||||
"""Test other perm denied"""
|
||||
self.client.force_login(self.user)
|
||||
app = Application.objects.create(name="denied", slug="denied")
|
||||
app = Application.objects.create(name=generate_id(), slug=generate_id())
|
||||
assign_perm("authentik_core.view_application", self.user, app)
|
||||
response = self.client.get(
|
||||
reverse("authentik_api:application-metrics", kwargs={"slug": app.slug})
|
||||
|
@ -58,6 +58,8 @@ from authentik.providers.oauth2.api.tokens import (
|
||||
from authentik.providers.proxy.api import ProxyOutpostConfigViewSet, ProxyProviderViewSet
|
||||
from authentik.providers.saml.api.property_mapping import SAMLPropertyMappingViewSet
|
||||
from authentik.providers.saml.api.providers import SAMLProviderViewSet
|
||||
from authentik.providers.scim.api.property_mapping import SCIMMappingViewSet
|
||||
from authentik.providers.scim.api.providers import SCIMProviderViewSet
|
||||
from authentik.sources.ldap.api import LDAPPropertyMappingViewSet, LDAPSourceViewSet
|
||||
from authentik.sources.oauth.api.source import OAuthSourceViewSet
|
||||
from authentik.sources.oauth.api.source_connection import UserOAuthSourceConnectionViewSet
|
||||
@ -163,6 +165,7 @@ router.register("providers/ldap", LDAPProviderViewSet)
|
||||
router.register("providers/proxy", ProxyProviderViewSet)
|
||||
router.register("providers/oauth2", OAuth2ProviderViewSet)
|
||||
router.register("providers/saml", SAMLProviderViewSet)
|
||||
router.register("providers/scim", SCIMProviderViewSet)
|
||||
|
||||
router.register("oauth2/authorization_codes", AuthorizationCodeViewSet)
|
||||
router.register("oauth2/refresh_tokens", RefreshTokenViewSet)
|
||||
@ -173,6 +176,7 @@ router.register("propertymappings/ldap", LDAPPropertyMappingViewSet)
|
||||
router.register("propertymappings/saml", SAMLPropertyMappingViewSet)
|
||||
router.register("propertymappings/scope", ScopeMappingViewSet)
|
||||
router.register("propertymappings/notification", NotificationWebhookMappingViewSet)
|
||||
router.register("propertymappings/scim", SCIMMappingViewSet)
|
||||
|
||||
router.register("authenticators/all", DeviceViewSet, basename="device")
|
||||
router.register("authenticators/duo", DuoDeviceViewSet)
|
||||
|
@ -37,7 +37,6 @@ from authentik.lib.utils.file import (
|
||||
from authentik.policies.api.exec import PolicyTestResultSerializer
|
||||
from authentik.policies.engine import PolicyEngine
|
||||
from authentik.policies.types import PolicyResult
|
||||
from authentik.stages.user_login.stage import USER_LOGIN_AUTHENTICATED
|
||||
|
||||
LOGGER = get_logger()
|
||||
|
||||
@ -186,10 +185,6 @@ class ApplicationViewSet(UsedByMixin, ModelViewSet):
|
||||
if superuser_full_list and request.user.is_superuser:
|
||||
return super().list(request)
|
||||
|
||||
# To prevent the user from having to double login when prompt is set to login
|
||||
# and the user has just signed it. This session variable is set in the UserLoginStage
|
||||
# and is (quite hackily) removed from the session in applications's API's List method
|
||||
self.request.session.pop(USER_LOGIN_AUTHENTICATED, None)
|
||||
queryset = self._filter_queryset_for_list(self.get_queryset())
|
||||
self.paginate_queryset(queryset)
|
||||
|
||||
|
@ -24,7 +24,6 @@ from authentik.core.models import Group, User
|
||||
class GroupMemberSerializer(ModelSerializer):
|
||||
"""Stripped down user serializer to show relevant users for groups"""
|
||||
|
||||
avatar = CharField(read_only=True)
|
||||
attributes = JSONField(validators=[is_dict], required=False)
|
||||
uid = CharField(read_only=True)
|
||||
|
||||
@ -37,7 +36,6 @@ class GroupMemberSerializer(ModelSerializer):
|
||||
"is_active",
|
||||
"last_login",
|
||||
"email",
|
||||
"avatar",
|
||||
"attributes",
|
||||
"uid",
|
||||
]
|
||||
|
@ -44,6 +44,9 @@ class ProviderSerializer(ModelSerializer, MetaNameSerializer):
|
||||
"verbose_name_plural",
|
||||
"meta_model_name",
|
||||
]
|
||||
extra_kwargs = {
|
||||
"authorization_flow": {"required": True, "allow_null": False},
|
||||
}
|
||||
|
||||
|
||||
class ProviderViewSet(
|
||||
|
@ -206,5 +206,6 @@ class UserSourceConnectionViewSet(
|
||||
queryset = UserSourceConnection.objects.all()
|
||||
serializer_class = UserSourceConnectionSerializer
|
||||
permission_classes = [OwnerSuperuserPermissions]
|
||||
filterset_fields = ["user"]
|
||||
filter_backends = [OwnerFilter, DjangoFilterBackend, OrderingFilter, SearchFilter]
|
||||
ordering = ["pk"]
|
||||
|
@ -31,8 +31,14 @@ class TokenSerializer(ManagedSerializer, ModelSerializer):
|
||||
|
||||
def validate(self, attrs: dict[Any, str]) -> dict[Any, str]:
|
||||
"""Ensure only API or App password tokens are created."""
|
||||
request: Request = self.context["request"]
|
||||
attrs.setdefault("user", request.user)
|
||||
request: Request = self.context.get("request")
|
||||
if not request:
|
||||
if "user" not in attrs:
|
||||
raise ValidationError("Missing user")
|
||||
if "intent" not in attrs:
|
||||
raise ValidationError("Missing intent")
|
||||
else:
|
||||
attrs.setdefault("user", request.user)
|
||||
attrs.setdefault("intent", TokenIntents.INTENT_API)
|
||||
if attrs.get("intent") not in [TokenIntents.INTENT_API, TokenIntents.INTENT_APP_PASSWORD]:
|
||||
raise ValidationError(f"Invalid intent {attrs.get('intent')}")
|
||||
|
@ -38,6 +38,7 @@ from rest_framework.request import Request
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.serializers import (
|
||||
BooleanField,
|
||||
DateTimeField,
|
||||
ListSerializer,
|
||||
ModelSerializer,
|
||||
PrimaryKeyRelatedField,
|
||||
@ -67,6 +68,7 @@ from authentik.core.models import (
|
||||
User,
|
||||
)
|
||||
from authentik.events.models import EventAction
|
||||
from authentik.flows.exceptions import FlowNonApplicableException
|
||||
from authentik.flows.models import FlowToken
|
||||
from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlanner
|
||||
from authentik.flows.views.executor import QS_KEY_TOKEN
|
||||
@ -325,12 +327,16 @@ class UserViewSet(UsedByMixin, ModelViewSet):
|
||||
user: User = self.get_object()
|
||||
planner = FlowPlanner(flow)
|
||||
planner.allow_empty_flows = True
|
||||
plan = planner.plan(
|
||||
self.request._request,
|
||||
{
|
||||
PLAN_CONTEXT_PENDING_USER: user,
|
||||
},
|
||||
)
|
||||
try:
|
||||
plan = planner.plan(
|
||||
self.request._request,
|
||||
{
|
||||
PLAN_CONTEXT_PENDING_USER: user,
|
||||
},
|
||||
)
|
||||
except FlowNonApplicableException:
|
||||
LOGGER.warning("Recovery flow not applicable to user")
|
||||
return None, None
|
||||
token, __ = FlowToken.objects.update_or_create(
|
||||
identifier=f"{user.uid}-password-reset",
|
||||
defaults={
|
||||
@ -353,6 +359,11 @@ class UserViewSet(UsedByMixin, ModelViewSet):
|
||||
{
|
||||
"name": CharField(required=True),
|
||||
"create_group": BooleanField(default=False),
|
||||
"expiring": BooleanField(default=True),
|
||||
"expires": DateTimeField(
|
||||
required=False,
|
||||
help_text="If not provided, valid for 360 days",
|
||||
),
|
||||
},
|
||||
),
|
||||
responses={
|
||||
@ -373,14 +384,20 @@ class UserViewSet(UsedByMixin, ModelViewSet):
|
||||
"""Create a new user account that is marked as a service account"""
|
||||
username = request.data.get("name")
|
||||
create_group = request.data.get("create_group", False)
|
||||
expiring = request.data.get("expiring", True)
|
||||
expires = request.data.get("expires", now() + timedelta(days=360))
|
||||
|
||||
with atomic():
|
||||
try:
|
||||
user = User.objects.create(
|
||||
user: User = User.objects.create(
|
||||
username=username,
|
||||
name=username,
|
||||
attributes={USER_ATTRIBUTE_SA: True, USER_ATTRIBUTE_TOKEN_EXPIRING: False},
|
||||
attributes={USER_ATTRIBUTE_SA: True, USER_ATTRIBUTE_TOKEN_EXPIRING: expiring},
|
||||
path=USER_PATH_SERVICE_ACCOUNT,
|
||||
)
|
||||
user.set_unusable_password()
|
||||
user.save()
|
||||
|
||||
response = {
|
||||
"username": user.username,
|
||||
"user_uid": user.uid,
|
||||
@ -396,7 +413,8 @@ class UserViewSet(UsedByMixin, ModelViewSet):
|
||||
identifier=slugify(f"service-account-{username}-password"),
|
||||
intent=TokenIntents.INTENT_APP_PASSWORD,
|
||||
user=user,
|
||||
expires=now() + timedelta(days=360),
|
||||
expires=expires,
|
||||
expiring=expiring,
|
||||
)
|
||||
response["token"] = token.key
|
||||
return Response(response)
|
||||
|
@ -1,8 +1,9 @@
|
||||
"""Property Mapping Evaluator"""
|
||||
from typing import Optional
|
||||
from typing import Any, Optional
|
||||
|
||||
from django.db.models import Model
|
||||
from django.http import HttpRequest
|
||||
from prometheus_client import Histogram
|
||||
|
||||
from authentik.core.models import User
|
||||
from authentik.events.models import Event, EventAction
|
||||
@ -10,6 +11,12 @@ 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(
|
||||
"authentik_property_mapping_execution_time",
|
||||
"Evaluation time of property mappings",
|
||||
["mapping_name"],
|
||||
)
|
||||
|
||||
|
||||
class PropertyMappingEvaluator(BaseEvaluator):
|
||||
"""Custom Evaluator that adds some different context variables."""
|
||||
@ -49,3 +56,7 @@ class PropertyMappingEvaluator(BaseEvaluator):
|
||||
event.from_http(req.http_request, req.user)
|
||||
return
|
||||
event.save()
|
||||
|
||||
def evaluate(self, *args, **kwargs) -> Any:
|
||||
with PROPERTY_MAPPING_TIME.labels(mapping_name=self._filename).time():
|
||||
return super().evaluate(*args, **kwargs)
|
||||
|
@ -18,13 +18,13 @@ def create_default_user(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
|
||||
db_alias = schema_editor.connection.alias
|
||||
|
||||
akadmin, _ = User.objects.using(db_alias).get_or_create(
|
||||
username="akadmin", email="root@localhost", name="authentik Default Admin"
|
||||
username="akadmin",
|
||||
email=environ.get("AUTHENTIK_BOOTSTRAP_EMAIL", "root@localhost"),
|
||||
name="authentik Default Admin",
|
||||
)
|
||||
password = None
|
||||
if "TF_BUILD" in environ or settings.TEST:
|
||||
password = "akadmin" # noqa # nosec
|
||||
if "AK_ADMIN_PASS" in environ:
|
||||
password = environ["AK_ADMIN_PASS"]
|
||||
if "AUTHENTIK_BOOTSTRAP_PASSWORD" in environ:
|
||||
password = environ["AUTHENTIK_BOOTSTRAP_PASSWORD"]
|
||||
if password:
|
||||
|
@ -46,13 +46,9 @@ def create_default_user_token(apps: Apps, schema_editor: BaseDatabaseSchemaEdito
|
||||
akadmin = User.objects.using(db_alias).filter(username="akadmin")
|
||||
if not akadmin.exists():
|
||||
return
|
||||
key = None
|
||||
if "AK_ADMIN_TOKEN" in environ:
|
||||
key = environ["AK_ADMIN_TOKEN"]
|
||||
if "AUTHENTIK_BOOTSTRAP_TOKEN" in environ:
|
||||
key = environ["AUTHENTIK_BOOTSTRAP_TOKEN"]
|
||||
if not key:
|
||||
if "AUTHENTIK_BOOTSTRAP_TOKEN" not in environ:
|
||||
return
|
||||
key = environ["AUTHENTIK_BOOTSTRAP_TOKEN"]
|
||||
Token.objects.using(db_alias).create(
|
||||
identifier="authentik-bootstrap-token",
|
||||
user=akadmin.first(),
|
||||
@ -186,7 +182,9 @@ class Migration(migrations.Migration):
|
||||
model_name="application",
|
||||
name="meta_launch_url",
|
||||
field=models.TextField(
|
||||
blank=True, default="", validators=[authentik.lib.models.DomainlessURLValidator()]
|
||||
blank=True,
|
||||
default="",
|
||||
validators=[authentik.lib.models.DomainlessFormattedURLValidator()],
|
||||
),
|
||||
),
|
||||
migrations.RunPython(
|
||||
|
@ -0,0 +1,25 @@
|
||||
# Generated by Django 4.1.7 on 2023-03-02 21:32
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("authentik_flows", "0025_alter_flowstagebinding_evaluate_on_plan_and_more"),
|
||||
("authentik_core", "0024_source_icon"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="provider",
|
||||
name="authorization_flow",
|
||||
field=models.ForeignKey(
|
||||
help_text="Flow used when authorizing this provider.",
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="provider_authorization",
|
||||
to="authentik_flows.flow",
|
||||
),
|
||||
),
|
||||
]
|
@ -0,0 +1,26 @@
|
||||
# Generated by Django 4.1.7 on 2023-03-07 13:41
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
from authentik.lib.migrations import fallback_names
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("authentik_core", "0025_alter_provider_authorization_flow"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(fallback_names("authentik_core", "propertymapping", "name")),
|
||||
migrations.RunPython(fallback_names("authentik_core", "provider", "name")),
|
||||
migrations.AlterField(
|
||||
model_name="propertymapping",
|
||||
name="name",
|
||||
field=models.TextField(unique=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="provider",
|
||||
name="name",
|
||||
field=models.TextField(unique=True),
|
||||
),
|
||||
]
|
@ -22,12 +22,15 @@ from structlog.stdlib import get_logger
|
||||
|
||||
from authentik.blueprints.models import ManagedModel
|
||||
from authentik.core.exceptions import PropertyMappingExpressionException
|
||||
from authentik.core.signals import password_changed
|
||||
from authentik.core.types import UILoginButton, UserSettingSerializer
|
||||
from authentik.lib.avatars import get_avatar
|
||||
from authentik.lib.config import CONFIG
|
||||
from authentik.lib.generators import generate_id
|
||||
from authentik.lib.models import CreatedUpdatedModel, DomainlessURLValidator, SerializerModel
|
||||
from authentik.lib.models import (
|
||||
CreatedUpdatedModel,
|
||||
DomainlessFormattedURLValidator,
|
||||
SerializerModel,
|
||||
)
|
||||
from authentik.lib.utils.http import get_client_ip
|
||||
from authentik.policies.models import PolicyBindingModel
|
||||
|
||||
@ -189,6 +192,8 @@ class User(SerializerModel, GuardianUserMixin, AbstractUser):
|
||||
|
||||
def set_password(self, raw_password, signal=True):
|
||||
if self.pk and signal:
|
||||
from authentik.core.signals import password_changed
|
||||
|
||||
password_changed.send(sender=self, user=self, password=raw_password)
|
||||
self.password_change_date = now()
|
||||
return super().set_password(raw_password)
|
||||
@ -242,11 +247,12 @@ class User(SerializerModel, GuardianUserMixin, AbstractUser):
|
||||
class Provider(SerializerModel):
|
||||
"""Application-independent Provider instance. For example SAML2 Remote, OAuth2 Application"""
|
||||
|
||||
name = models.TextField()
|
||||
name = models.TextField(unique=True)
|
||||
|
||||
authorization_flow = models.ForeignKey(
|
||||
"authentik_flows.Flow",
|
||||
on_delete=models.CASCADE,
|
||||
null=True,
|
||||
help_text=_("Flow used when authorizing this provider."),
|
||||
related_name="provider_authorization",
|
||||
)
|
||||
@ -289,7 +295,7 @@ class Application(SerializerModel, PolicyBindingModel):
|
||||
)
|
||||
|
||||
meta_launch_url = models.TextField(
|
||||
default="", blank=True, validators=[DomainlessURLValidator()]
|
||||
default="", blank=True, validators=[DomainlessFormattedURLValidator()]
|
||||
)
|
||||
|
||||
open_in_new_tab = models.BooleanField(
|
||||
@ -606,7 +612,7 @@ class PropertyMapping(SerializerModel, ManagedModel):
|
||||
"""User-defined key -> x mapping which can be used by providers to expose extra data."""
|
||||
|
||||
pm_uuid = models.UUIDField(primary_key=True, editable=False, default=uuid4)
|
||||
name = models.TextField()
|
||||
name = models.TextField(unique=True)
|
||||
expression = models.TextField()
|
||||
|
||||
objects = InheritanceManager()
|
||||
@ -629,7 +635,7 @@ class PropertyMapping(SerializerModel, ManagedModel):
|
||||
try:
|
||||
return evaluator.evaluate(self.expression)
|
||||
except Exception as exc:
|
||||
raise PropertyMappingExpressionException(str(exc)) from exc
|
||||
raise PropertyMappingExpressionException(exc) from exc
|
||||
|
||||
def __str__(self):
|
||||
return f"Property Mapping {self.name}"
|
||||
|
@ -10,25 +10,25 @@ from django.db.models.signals import post_save, pre_delete
|
||||
from django.dispatch import receiver
|
||||
from django.http.request import HttpRequest
|
||||
|
||||
from authentik.core.models import Application, AuthenticatedSession
|
||||
|
||||
# Arguments: user: User, password: str
|
||||
password_changed = Signal()
|
||||
# Arguments: credentials: dict[str, any], request: HttpRequest, stage: Stage
|
||||
login_failed = Signal()
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from authentik.core.models import AuthenticatedSession, User
|
||||
from authentik.core.models import User
|
||||
|
||||
|
||||
@receiver(post_save)
|
||||
@receiver(post_save, sender=Application)
|
||||
def post_save_application(sender: type[Model], instance, created: bool, **_):
|
||||
"""Clear user's application cache upon application creation"""
|
||||
from authentik.core.api.applications import user_app_cache_key
|
||||
from authentik.core.models import Application
|
||||
|
||||
if sender != Application:
|
||||
return
|
||||
if not created: # pragma: no cover
|
||||
return
|
||||
|
||||
# Also delete user application cache
|
||||
keys = cache.keys(user_app_cache_key("*"))
|
||||
cache.delete_many(keys)
|
||||
@ -37,7 +37,6 @@ def post_save_application(sender: type[Model], instance, created: bool, **_):
|
||||
@receiver(user_logged_in)
|
||||
def user_logged_in_session(sender, request: HttpRequest, user: "User", **_):
|
||||
"""Create an AuthenticatedSession from request"""
|
||||
from authentik.core.models import AuthenticatedSession
|
||||
|
||||
session = AuthenticatedSession.from_request(request, user)
|
||||
if session:
|
||||
@ -47,18 +46,11 @@ def user_logged_in_session(sender, request: HttpRequest, user: "User", **_):
|
||||
@receiver(user_logged_out)
|
||||
def user_logged_out_session(sender, request: HttpRequest, user: "User", **_):
|
||||
"""Delete AuthenticatedSession if it exists"""
|
||||
from authentik.core.models import AuthenticatedSession
|
||||
|
||||
AuthenticatedSession.objects.filter(session_key=request.session.session_key).delete()
|
||||
|
||||
|
||||
@receiver(pre_delete)
|
||||
@receiver(pre_delete, sender=AuthenticatedSession)
|
||||
def authenticated_session_delete(sender: type[Model], instance: "AuthenticatedSession", **_):
|
||||
"""Delete session when authenticated session is deleted"""
|
||||
from authentik.core.models import AuthenticatedSession
|
||||
|
||||
if sender != AuthenticatedSession:
|
||||
return
|
||||
|
||||
cache_key = f"{KEY_PREFIX}{instance.session_key}"
|
||||
cache.delete(cache_key)
|
||||
|
@ -16,7 +16,8 @@
|
||||
{% block head_before %}
|
||||
{% endblock %}
|
||||
<link rel="stylesheet" type="text/css" href="{% static 'dist/authentik.css' %}">
|
||||
<link rel="stylesheet" type="text/css" href="{% static 'dist/custom.css' %}">
|
||||
<link rel="stylesheet" type="text/css" href="{% static 'dist/theme-dark.css' %}" media="(prefers-color-scheme: dark)">
|
||||
<link rel="stylesheet" type="text/css" href="{% static 'dist/custom.css' %}" data-inject>
|
||||
<script src="{% static 'dist/poly.js' %}" type="module"></script>
|
||||
{% block head %}
|
||||
{% endblock %}
|
||||
|
@ -37,6 +37,22 @@ class TestApplicationsAPI(APITestCase):
|
||||
order=0,
|
||||
)
|
||||
|
||||
def test_formatted_launch_url(self):
|
||||
"""Test formatted launch URL"""
|
||||
self.client.force_login(self.user)
|
||||
self.assertEqual(
|
||||
self.client.patch(
|
||||
reverse("authentik_api:application-detail", kwargs={"slug": self.allowed.slug}),
|
||||
{"meta_launch_url": "https://%(username)s.test.goauthentik.io/%(username)s"},
|
||||
).status_code,
|
||||
200,
|
||||
)
|
||||
self.allowed.refresh_from_db()
|
||||
self.assertEqual(
|
||||
self.allowed.get_launch_url(self.user),
|
||||
f"https://{self.user.username}.test.goauthentik.io/{self.user.username}",
|
||||
)
|
||||
|
||||
def test_set_icon(self):
|
||||
"""Test set_icon"""
|
||||
file = ContentFile(b"text", "name")
|
||||
|
@ -5,6 +5,7 @@ from django.urls.base import reverse
|
||||
from guardian.shortcuts import get_anonymous_user
|
||||
from rest_framework.test import APITestCase
|
||||
|
||||
from authentik.core.api.tokens import TokenSerializer
|
||||
from authentik.core.models import USER_ATTRIBUTE_TOKEN_EXPIRING, Token, TokenIntents, User
|
||||
from authentik.core.tests.utils import create_test_admin_user
|
||||
from authentik.lib.generators import generate_id
|
||||
@ -99,3 +100,16 @@ class TestTokenAPI(APITestCase):
|
||||
self.assertEqual(len(body["results"]), 2)
|
||||
self.assertEqual(body["results"][0]["identifier"], token_should.identifier)
|
||||
self.assertEqual(body["results"][1]["identifier"], token_should_not.identifier)
|
||||
|
||||
def test_serializer_no_request(self):
|
||||
"""Test serializer without request"""
|
||||
self.assertTrue(
|
||||
TokenSerializer(
|
||||
data={
|
||||
"identifier": generate_id(),
|
||||
"intent": TokenIntents.INTENT_APP_PASSWORD,
|
||||
"key": generate_id(),
|
||||
"user": self.user.pk,
|
||||
}
|
||||
).is_valid(raise_exception=True)
|
||||
)
|
||||
|
@ -1,11 +1,19 @@
|
||||
"""Test Users API"""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from django.contrib.sessions.backends.cache import KEY_PREFIX
|
||||
from django.core.cache import cache
|
||||
from django.urls.base import reverse
|
||||
from rest_framework.test import APITestCase
|
||||
|
||||
from authentik.core.models import AuthenticatedSession, User
|
||||
from authentik.core.models import (
|
||||
USER_ATTRIBUTE_SA,
|
||||
USER_ATTRIBUTE_TOKEN_EXPIRING,
|
||||
AuthenticatedSession,
|
||||
Token,
|
||||
User,
|
||||
)
|
||||
from authentik.core.tests.utils import create_test_admin_user, create_test_flow, create_test_tenant
|
||||
from authentik.flows.models import FlowDesignation
|
||||
from authentik.lib.generators import generate_id, generate_key
|
||||
@ -130,7 +138,71 @@ class TestUsersAPI(APITestCase):
|
||||
},
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertTrue(User.objects.filter(username="test-sa").exists())
|
||||
|
||||
user_filter = User.objects.filter(
|
||||
username="test-sa",
|
||||
attributes={USER_ATTRIBUTE_TOKEN_EXPIRING: True, USER_ATTRIBUTE_SA: True},
|
||||
)
|
||||
self.assertTrue(user_filter.exists())
|
||||
user: User = user_filter.first()
|
||||
self.assertFalse(user.has_usable_password())
|
||||
|
||||
token_filter = Token.objects.filter(user=user)
|
||||
self.assertTrue(token_filter.exists())
|
||||
self.assertTrue(token_filter.first().expiring)
|
||||
|
||||
def test_service_account_no_expire(self):
|
||||
"""Service account creation without token expiration"""
|
||||
self.client.force_login(self.admin)
|
||||
response = self.client.post(
|
||||
reverse("authentik_api:user-service-account"),
|
||||
data={
|
||||
"name": "test-sa",
|
||||
"create_group": True,
|
||||
"expiring": False,
|
||||
},
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
user_filter = User.objects.filter(
|
||||
username="test-sa",
|
||||
attributes={USER_ATTRIBUTE_TOKEN_EXPIRING: False, USER_ATTRIBUTE_SA: True},
|
||||
)
|
||||
self.assertTrue(user_filter.exists())
|
||||
user: User = user_filter.first()
|
||||
self.assertFalse(user.has_usable_password())
|
||||
|
||||
token_filter = Token.objects.filter(user=user)
|
||||
self.assertTrue(token_filter.exists())
|
||||
self.assertFalse(token_filter.first().expiring)
|
||||
|
||||
def test_service_account_with_custom_expire(self):
|
||||
"""Service account creation with custom token expiration date"""
|
||||
self.client.force_login(self.admin)
|
||||
expire_on = datetime(2050, 11, 11, 11, 11, 11).astimezone()
|
||||
response = self.client.post(
|
||||
reverse("authentik_api:user-service-account"),
|
||||
data={
|
||||
"name": "test-sa",
|
||||
"create_group": True,
|
||||
"expires": expire_on.isoformat(),
|
||||
},
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
user_filter = User.objects.filter(
|
||||
username="test-sa",
|
||||
attributes={USER_ATTRIBUTE_TOKEN_EXPIRING: True, USER_ATTRIBUTE_SA: True},
|
||||
)
|
||||
self.assertTrue(user_filter.exists())
|
||||
user: User = user_filter.first()
|
||||
self.assertFalse(user.has_usable_password())
|
||||
|
||||
token_filter = Token.objects.filter(user=user)
|
||||
self.assertTrue(token_filter.exists())
|
||||
token = token_filter.first()
|
||||
self.assertTrue(token.expiring)
|
||||
self.assertEqual(token.expires, expire_on)
|
||||
|
||||
def test_service_account_invalid(self):
|
||||
"""Service account creation (twice with same name, expect error)"""
|
||||
@ -143,7 +215,19 @@ class TestUsersAPI(APITestCase):
|
||||
},
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertTrue(User.objects.filter(username="test-sa").exists())
|
||||
|
||||
user_filter = User.objects.filter(
|
||||
username="test-sa",
|
||||
attributes={USER_ATTRIBUTE_TOKEN_EXPIRING: True, USER_ATTRIBUTE_SA: True},
|
||||
)
|
||||
self.assertTrue(user_filter.exists())
|
||||
user: User = user_filter.first()
|
||||
self.assertFalse(user.has_usable_password())
|
||||
|
||||
token_filter = Token.objects.filter(user=user)
|
||||
self.assertTrue(token_filter.exists())
|
||||
self.assertTrue(token_filter.first().expiring)
|
||||
|
||||
response = self.client.post(
|
||||
reverse("authentik_api:user-service-account"),
|
||||
data={
|
||||
|
@ -11,6 +11,7 @@ from authentik.flows.challenge import (
|
||||
HttpChallengeResponse,
|
||||
RedirectChallenge,
|
||||
)
|
||||
from authentik.flows.exceptions import FlowNonApplicableException
|
||||
from authentik.flows.models import in_memory_stage
|
||||
from authentik.flows.planner import PLAN_CONTEXT_APPLICATION, FlowPlanner
|
||||
from authentik.flows.stage import ChallengeStageView
|
||||
@ -41,15 +42,18 @@ class RedirectToAppLaunch(View):
|
||||
flow = tenant.flow_authentication
|
||||
planner = FlowPlanner(flow)
|
||||
planner.allow_empty_flows = True
|
||||
plan = planner.plan(
|
||||
request,
|
||||
{
|
||||
PLAN_CONTEXT_APPLICATION: app,
|
||||
PLAN_CONTEXT_CONSENT_HEADER: _("You're about to sign into %(application)s.")
|
||||
% {"application": app.name},
|
||||
PLAN_CONTEXT_CONSENT_PERMISSIONS: [],
|
||||
},
|
||||
)
|
||||
try:
|
||||
plan = planner.plan(
|
||||
request,
|
||||
{
|
||||
PLAN_CONTEXT_APPLICATION: app,
|
||||
PLAN_CONTEXT_CONSENT_HEADER: _("You're about to sign into %(application)s.")
|
||||
% {"application": app.name},
|
||||
PLAN_CONTEXT_CONSENT_PERMISSIONS: [],
|
||||
},
|
||||
)
|
||||
except FlowNonApplicableException:
|
||||
raise Http404
|
||||
plan.insert_stage(in_memory_stage(RedirectToAppStage))
|
||||
request.session[SESSION_KEY_PLAN] = plan
|
||||
return redirect_with_qs("authentik_core:if-flow", request.GET, flow_slug=flow.slug)
|
||||
|
@ -7,13 +7,14 @@ from django.conf import settings
|
||||
from django.contrib.sessions.models import Session
|
||||
from django.core.exceptions import SuspiciousOperation
|
||||
from django.db.models import Model
|
||||
from django.db.models.signals import post_save, pre_delete
|
||||
from django.db.models.signals import m2m_changed, post_save, pre_delete
|
||||
from django.http import HttpRequest, HttpResponse
|
||||
from django_otp.plugins.otp_static.models import StaticToken
|
||||
from guardian.models import UserObjectPermission
|
||||
|
||||
from authentik.core.models import (
|
||||
AuthenticatedSession,
|
||||
Group,
|
||||
PropertyMapping,
|
||||
Provider,
|
||||
Source,
|
||||
@ -28,6 +29,7 @@ from authentik.lib.utils.errors import exception_to_string
|
||||
from authentik.outposts.models import OutpostServiceConnection
|
||||
from authentik.policies.models import Policy, PolicyBindingModel
|
||||
from authentik.providers.oauth2.models import AccessToken, AuthorizationCode, RefreshToken
|
||||
from authentik.providers.scim.models import SCIMGroup, SCIMUser
|
||||
|
||||
IGNORED_MODELS = (
|
||||
Event,
|
||||
@ -48,6 +50,8 @@ IGNORED_MODELS = (
|
||||
AuthorizationCode,
|
||||
AccessToken,
|
||||
RefreshToken,
|
||||
SCIMUser,
|
||||
SCIMGroup,
|
||||
)
|
||||
|
||||
|
||||
@ -58,6 +62,13 @@ def should_log_model(model: Model) -> bool:
|
||||
return model.__class__ not in IGNORED_MODELS
|
||||
|
||||
|
||||
def should_log_m2m(model: Model) -> bool:
|
||||
"""Return true if m2m operation should be logged"""
|
||||
if model.__class__ in [User, Group]:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
class EventNewThread(Thread):
|
||||
"""Create Event in background thread"""
|
||||
|
||||
@ -96,6 +107,7 @@ class AuditMiddleware:
|
||||
return
|
||||
post_save_handler = partial(self.post_save_handler, user=request.user, request=request)
|
||||
pre_delete_handler = partial(self.pre_delete_handler, user=request.user, request=request)
|
||||
m2m_changed_handler = partial(self.m2m_changed_handler, user=request.user, request=request)
|
||||
post_save.connect(
|
||||
post_save_handler,
|
||||
dispatch_uid=request.request_id,
|
||||
@ -106,6 +118,11 @@ class AuditMiddleware:
|
||||
dispatch_uid=request.request_id,
|
||||
weak=False,
|
||||
)
|
||||
m2m_changed.connect(
|
||||
m2m_changed_handler,
|
||||
dispatch_uid=request.request_id,
|
||||
weak=False,
|
||||
)
|
||||
|
||||
def disconnect(self, request: HttpRequest):
|
||||
"""Disconnect signals"""
|
||||
@ -113,6 +130,7 @@ class AuditMiddleware:
|
||||
return
|
||||
post_save.disconnect(dispatch_uid=request.request_id)
|
||||
pre_delete.disconnect(dispatch_uid=request.request_id)
|
||||
m2m_changed.disconnect(dispatch_uid=request.request_id)
|
||||
|
||||
def __call__(self, request: HttpRequest) -> HttpResponse:
|
||||
self.connect(request)
|
||||
@ -167,3 +185,20 @@ class AuditMiddleware:
|
||||
user=user,
|
||||
model=model_to_dict(instance),
|
||||
).run()
|
||||
|
||||
@staticmethod
|
||||
def m2m_changed_handler(
|
||||
user: User, request: HttpRequest, sender, instance: Model, action: str, **_
|
||||
):
|
||||
"""Signal handler for all object's m2m_changed"""
|
||||
if action not in ["pre_add", "pre_remove", "post_clear"]:
|
||||
return
|
||||
if not should_log_m2m(instance):
|
||||
return
|
||||
|
||||
EventNewThread(
|
||||
EventAction.MODEL_UPDATED,
|
||||
request,
|
||||
user=user,
|
||||
model=model_to_dict(instance),
|
||||
).run()
|
||||
|
@ -2,7 +2,6 @@
|
||||
|
||||
import uuid
|
||||
from datetime import timedelta
|
||||
from typing import Iterable
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.apps.registry import Apps
|
||||
@ -13,6 +12,7 @@ from django.db.backends.base.schema import BaseDatabaseSchemaEditor
|
||||
import authentik.events.models
|
||||
import authentik.lib.models
|
||||
from authentik.events.models import EventAction, NotificationSeverity, TransportMode
|
||||
from authentik.lib.migrations import progress_bar
|
||||
|
||||
|
||||
def convert_user_to_json(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
|
||||
@ -43,49 +43,6 @@ def token_view_to_secret_view(apps: Apps, schema_editor: BaseDatabaseSchemaEdito
|
||||
Event.objects.using(db_alias).bulk_update(events, ["context", "action"])
|
||||
|
||||
|
||||
# Taken from https://stackoverflow.com/questions/3173320/text-progress-bar-in-the-console
|
||||
def progress_bar(
|
||||
iterable: Iterable,
|
||||
prefix="Writing: ",
|
||||
suffix=" finished",
|
||||
decimals=1,
|
||||
length=100,
|
||||
fill="█",
|
||||
print_end="\r",
|
||||
):
|
||||
"""
|
||||
Call in a loop to create terminal progress bar
|
||||
@params:
|
||||
iteration - Required : current iteration (Int)
|
||||
total - Required : total iterations (Int)
|
||||
prefix - Optional : prefix string (Str)
|
||||
suffix - Optional : suffix string (Str)
|
||||
decimals - Optional : positive number of decimals in percent complete (Int)
|
||||
length - Optional : character length of bar (Int)
|
||||
fill - Optional : bar fill character (Str)
|
||||
print_end - Optional : end character (e.g. "\r", "\r\n") (Str)
|
||||
"""
|
||||
total = len(iterable)
|
||||
if total < 1:
|
||||
return
|
||||
|
||||
def print_progress_bar(iteration):
|
||||
"""Progress Bar Printing Function"""
|
||||
percent = ("{0:." + str(decimals) + "f}").format(100 * (iteration / float(total)))
|
||||
filledLength = int(length * iteration // total)
|
||||
bar = fill * filledLength + "-" * (length - filledLength)
|
||||
print(f"\r{prefix} |{bar}| {percent}% {suffix}", end=print_end)
|
||||
|
||||
# Initial Call
|
||||
print_progress_bar(0)
|
||||
# Update Progress Bar
|
||||
for i, item in enumerate(iterable):
|
||||
yield item
|
||||
print_progress_bar(i + 1)
|
||||
# Print New Line on Complete
|
||||
print()
|
||||
|
||||
|
||||
def update_expires(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
|
||||
db_alias = schema_editor.connection.alias
|
||||
Event = apps.get_model("authentik_events", "event")
|
||||
|
@ -111,6 +111,7 @@ class MonitoredTask(Task):
|
||||
_result: Optional[TaskResult]
|
||||
|
||||
_uid: Optional[str]
|
||||
start: Optional[float] = None
|
||||
|
||||
def __init__(self, *args, **kwargs) -> None:
|
||||
super().__init__(*args, **kwargs)
|
||||
@ -118,7 +119,6 @@ class MonitoredTask(Task):
|
||||
self._uid = None
|
||||
self._result = None
|
||||
self.result_timeout_hours = 6
|
||||
self.start = default_timer()
|
||||
|
||||
def set_uid(self, uid: str):
|
||||
"""Set UID, so in the case of an unexpected error its saved correctly"""
|
||||
@ -128,6 +128,10 @@ class MonitoredTask(Task):
|
||||
"""Set result for current run, will overwrite previous result."""
|
||||
self._result = result
|
||||
|
||||
def before_start(self, task_id, args, kwargs):
|
||||
self.start = default_timer()
|
||||
return super().before_start(task_id, args, kwargs)
|
||||
|
||||
# pylint: disable=too-many-arguments
|
||||
def after_return(self, status, retval, task_id, args: list[Any], kwargs: dict[str, Any], einfo):
|
||||
super().after_return(status, retval, task_id, args, kwargs, einfo=einfo)
|
||||
@ -138,7 +142,7 @@ class MonitoredTask(Task):
|
||||
info = TaskInfo(
|
||||
task_name=self.__name__,
|
||||
task_description=self.__doc__,
|
||||
start_timestamp=self.start,
|
||||
start_timestamp=self.start or default_timer(),
|
||||
finish_timestamp=default_timer(),
|
||||
finish_time=datetime.now(),
|
||||
result=self._result,
|
||||
@ -162,7 +166,7 @@ class MonitoredTask(Task):
|
||||
TaskInfo(
|
||||
task_name=self.__name__,
|
||||
task_description=self.__doc__,
|
||||
start_timestamp=self.start,
|
||||
start_timestamp=self.start or default_timer(),
|
||||
finish_timestamp=default_timer(),
|
||||
finish_time=datetime.now(),
|
||||
result=self._result,
|
||||
|
@ -1,4 +1,7 @@
|
||||
"""Flow Binding API Views"""
|
||||
from typing import Any
|
||||
|
||||
from rest_framework.exceptions import ValidationError
|
||||
from rest_framework.serializers import ModelSerializer
|
||||
from rest_framework.viewsets import ModelViewSet
|
||||
|
||||
@ -12,6 +15,13 @@ class FlowStageBindingSerializer(ModelSerializer):
|
||||
|
||||
stage_obj = StageSerializer(read_only=True, source="stage")
|
||||
|
||||
def validate(self, attrs: dict[str, Any]) -> dict[str, Any]:
|
||||
evaluate_on_plan = attrs.get("evaluate_on_plan", False)
|
||||
re_evaluate_policies = attrs.get("re_evaluate_policies", True)
|
||||
if not evaluate_on_plan and not re_evaluate_policies:
|
||||
raise ValidationError("Either evaluation on plan or evaluation on run must be enabled")
|
||||
return super().validate(attrs)
|
||||
|
||||
class Meta:
|
||||
model = FlowStageBinding
|
||||
fields = [
|
||||
|
@ -8,7 +8,7 @@ from rest_framework.serializers import CharField
|
||||
|
||||
from authentik.core.api.utils import PassiveSerializer
|
||||
from authentik.core.models import User
|
||||
from authentik.flows.models import Flow, FlowStageBinding
|
||||
from authentik.flows.models import Flow, FlowAuthenticationRequirement, FlowStageBinding
|
||||
|
||||
|
||||
@dataclass
|
||||
@ -160,12 +160,37 @@ class FlowDiagram:
|
||||
)
|
||||
return stages + elements
|
||||
|
||||
def get_flow_auth_requirement(self) -> list[DiagramElement]:
|
||||
"""Get flow authentication requirement"""
|
||||
end_el = DiagramElement(
|
||||
"done",
|
||||
_("End of the flow"),
|
||||
_("Requirement not fulfilled"),
|
||||
style=["[[", "]]"],
|
||||
)
|
||||
elements = []
|
||||
if self.flow.authentication == FlowAuthenticationRequirement.NONE:
|
||||
return []
|
||||
auth = DiagramElement(
|
||||
"flow_auth_requirement",
|
||||
_("Flow authentication requirement") + "\n" + self.flow.authentication,
|
||||
)
|
||||
elements.append(auth)
|
||||
end_el.source = [auth]
|
||||
elements.append(end_el)
|
||||
elements.append(
|
||||
DiagramElement("flow_start", "placeholder", _("Requirement fulfilled"), source=[auth])
|
||||
)
|
||||
return elements
|
||||
|
||||
def build(self) -> str:
|
||||
"""Build flowchart"""
|
||||
all_elements = [
|
||||
"graph TD",
|
||||
]
|
||||
|
||||
all_elements.extend(self.get_flow_auth_requirement())
|
||||
|
||||
pre_flow_policies_element = DiagramElement(
|
||||
"flow_pre", _("Pre-flow policies"), style=["[[", "]]"]
|
||||
)
|
||||
@ -179,6 +204,7 @@ class FlowDiagram:
|
||||
_("End of the flow"),
|
||||
_("Policy denied"),
|
||||
flow_policies,
|
||||
style=["[[", "]]"],
|
||||
)
|
||||
)
|
||||
|
||||
|
@ -0,0 +1,26 @@
|
||||
# Generated by Django 4.1.7 on 2023-02-25 15:51
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("authentik_flows", "0024_flow_authentication"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="flowstagebinding",
|
||||
name="evaluate_on_plan",
|
||||
field=models.BooleanField(
|
||||
default=False, help_text="Evaluate policies during the Flow planning process."
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="flowstagebinding",
|
||||
name="re_evaluate_policies",
|
||||
field=models.BooleanField(
|
||||
default=True, help_text="Evaluate policies when the Stage is present to the user."
|
||||
),
|
||||
),
|
||||
]
|
@ -211,14 +211,11 @@ class FlowStageBinding(SerializerModel, PolicyBindingModel):
|
||||
stage = InheritanceForeignKey(Stage, on_delete=models.CASCADE)
|
||||
|
||||
evaluate_on_plan = models.BooleanField(
|
||||
default=True,
|
||||
help_text=_(
|
||||
"Evaluate policies during the Flow planning process. "
|
||||
"Disable this for input-based policies."
|
||||
),
|
||||
default=False,
|
||||
help_text=_("Evaluate policies during the Flow planning process."),
|
||||
)
|
||||
re_evaluate_policies = models.BooleanField(
|
||||
default=False,
|
||||
default=True,
|
||||
help_text=_("Evaluate policies when the Stage is present to the user."),
|
||||
)
|
||||
|
||||
|
@ -147,7 +147,6 @@ class FlowPlanner:
|
||||
) -> FlowPlan:
|
||||
"""Check each of the flows' policies, check policies for each stage with PolicyBinding
|
||||
and return ordered list"""
|
||||
self._check_authentication(request)
|
||||
with Hub.current.start_span(
|
||||
op="authentik.flow.planner.plan", description=self.flow.slug
|
||||
) as span:
|
||||
@ -165,6 +164,12 @@ class FlowPlanner:
|
||||
user = default_context[PLAN_CONTEXT_PENDING_USER]
|
||||
else:
|
||||
user = request.user
|
||||
# We only need to check the flow authentication if it's planned without a user
|
||||
# in the context, as a user in the context can only be set via the explicit code API
|
||||
# or if a flow is restarted due to `invalid_response_action` being set to
|
||||
# `restart_with_context`, which can only happen if the user was already authorized
|
||||
# to use the flow
|
||||
self._check_authentication(request)
|
||||
# First off, check the flow's direct policy bindings
|
||||
# to make sure the user even has access to the flow
|
||||
engine = PolicyEngine(self.flow, user, request)
|
||||
@ -261,7 +266,6 @@ class FlowPlanner:
|
||||
marker = ReevaluateMarker(binding=binding)
|
||||
if stage:
|
||||
plan.append(binding, marker)
|
||||
HIST_FLOWS_PLAN_TIME.labels(flow_slug=self.flow.slug)
|
||||
self._logger.debug(
|
||||
"f(plan): finished building",
|
||||
)
|
||||
|
@ -7,6 +7,7 @@ from django.http.request import QueryDict
|
||||
from django.http.response import HttpResponse
|
||||
from django.urls import reverse
|
||||
from django.views.generic.base import View
|
||||
from prometheus_client import Histogram
|
||||
from rest_framework.request import Request
|
||||
from sentry_sdk.hub import Hub
|
||||
from structlog.stdlib import BoundLogger, get_logger
|
||||
@ -31,6 +32,11 @@ if TYPE_CHECKING:
|
||||
from authentik.flows.views.executor import FlowExecutorView
|
||||
|
||||
PLAN_CONTEXT_PENDING_USER_IDENTIFIER = "pending_user_identifier"
|
||||
HIST_FLOWS_STAGE_TIME = Histogram(
|
||||
"authentik_flows_stage_time",
|
||||
"Duration taken by different parts of stages",
|
||||
["stage_type", "method"],
|
||||
)
|
||||
|
||||
|
||||
class StageView(View):
|
||||
@ -109,14 +115,24 @@ class ChallengeStageView(StageView):
|
||||
keep_context=keep_context,
|
||||
)
|
||||
return self.executor.restart_flow(keep_context)
|
||||
with Hub.current.start_span(
|
||||
op="authentik.flow.stage.challenge_invalid",
|
||||
description=self.__class__.__name__,
|
||||
with (
|
||||
Hub.current.start_span(
|
||||
op="authentik.flow.stage.challenge_invalid",
|
||||
description=self.__class__.__name__,
|
||||
),
|
||||
HIST_FLOWS_STAGE_TIME.labels(
|
||||
stage_type=self.__class__.__name__, method="challenge_invalid"
|
||||
).time(),
|
||||
):
|
||||
return self.challenge_invalid(challenge)
|
||||
with Hub.current.start_span(
|
||||
op="authentik.flow.stage.challenge_valid",
|
||||
description=self.__class__.__name__,
|
||||
with (
|
||||
Hub.current.start_span(
|
||||
op="authentik.flow.stage.challenge_valid",
|
||||
description=self.__class__.__name__,
|
||||
),
|
||||
HIST_FLOWS_STAGE_TIME.labels(
|
||||
stage_type=self.__class__.__name__, method="challenge_valid"
|
||||
).time(),
|
||||
):
|
||||
return self.challenge_valid(challenge)
|
||||
|
||||
@ -135,9 +151,14 @@ class ChallengeStageView(StageView):
|
||||
return self.executor.flow.title
|
||||
|
||||
def _get_challenge(self, *args, **kwargs) -> Challenge:
|
||||
with Hub.current.start_span(
|
||||
op="authentik.flow.stage.get_challenge",
|
||||
description=self.__class__.__name__,
|
||||
with (
|
||||
Hub.current.start_span(
|
||||
op="authentik.flow.stage.get_challenge",
|
||||
description=self.__class__.__name__,
|
||||
),
|
||||
HIST_FLOWS_STAGE_TIME.labels(
|
||||
stage_type=self.__class__.__name__, method="get_challenge"
|
||||
).time(),
|
||||
):
|
||||
challenge = self.get_challenge(*args, **kwargs)
|
||||
with Hub.current.start_span(
|
||||
@ -210,7 +231,7 @@ class AccessDeniedChallengeView(ChallengeStageView):
|
||||
def get_challenge(self, *args, **kwargs) -> Challenge:
|
||||
return AccessDeniedChallenge(
|
||||
data={
|
||||
"error_message": self.error_message or "Unknown error",
|
||||
"error_message": str(self.error_message or "Unknown error"),
|
||||
"type": ChallengeTypes.NATIVE.value,
|
||||
"component": "ak-stage-access-denied",
|
||||
}
|
||||
|
@ -162,7 +162,7 @@ class FlowExecutorView(APIView):
|
||||
token.delete()
|
||||
if not isinstance(plan, FlowPlan):
|
||||
return None
|
||||
plan.context[PLAN_CONTEXT_IS_RESTORED] = True
|
||||
plan.context[PLAN_CONTEXT_IS_RESTORED] = token
|
||||
self._logger.debug("f(exec): restored flow plan from token", plan=plan)
|
||||
return plan
|
||||
|
||||
@ -561,9 +561,13 @@ class ConfigureFlowInitView(LoginRequiredMixin, View):
|
||||
LOGGER.debug("Stage has no configure_flow set", stage=stage)
|
||||
raise Http404
|
||||
|
||||
plan = FlowPlanner(stage.configure_flow).plan(
|
||||
request, {PLAN_CONTEXT_PENDING_USER: request.user}
|
||||
)
|
||||
try:
|
||||
plan = FlowPlanner(stage.configure_flow).plan(
|
||||
request, {PLAN_CONTEXT_PENDING_USER: request.user}
|
||||
)
|
||||
except FlowNonApplicableException:
|
||||
LOGGER.warning("Flow not applicable to user")
|
||||
raise Http404
|
||||
request.session[SESSION_KEY_PLAN] = plan
|
||||
return redirect_with_qs(
|
||||
"authentik_core:if-flow",
|
||||
|
@ -1,10 +1,11 @@
|
||||
"""Avatar utils"""
|
||||
from base64 import b64encode
|
||||
from functools import cache
|
||||
from functools import cache as funccache
|
||||
from hashlib import md5
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from django.core.cache import cache
|
||||
from django.templatetags.static import static
|
||||
from lxml import etree # nosec
|
||||
from lxml.etree import Element, SubElement # nosec
|
||||
@ -15,6 +16,7 @@ from authentik.lib.utils.http import get_http_session
|
||||
|
||||
GRAVATAR_URL = "https://secure.gravatar.com"
|
||||
DEFAULT_AVATAR = static("dist/assets/images/user_default.png")
|
||||
CACHE_KEY_GRAVATAR = "goauthentik.io/lib/avatars/"
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from authentik.core.models import User
|
||||
@ -50,22 +52,24 @@ def avatar_mode_gravatar(user: "User", mode: str) -> Optional[str]:
|
||||
parameters = [("size", "158"), ("rating", "g"), ("default", "404")]
|
||||
gravatar_url = f"{GRAVATAR_URL}/avatar/{mail_hash}?{urlencode(parameters, doseq=True)}"
|
||||
|
||||
@cache
|
||||
def check_non_default(url: str):
|
||||
"""Cache HEAD check, based on URL"""
|
||||
try:
|
||||
# Since we specify a default of 404, do a HEAD request
|
||||
# (HEAD since we don't need the body)
|
||||
# so if that returns a 404, move onto the next mode
|
||||
res = get_http_session().head(url, timeout=5)
|
||||
if res.status_code == 404:
|
||||
return None
|
||||
res.raise_for_status()
|
||||
except RequestException:
|
||||
return url
|
||||
return url
|
||||
full_key = CACHE_KEY_GRAVATAR + mail_hash
|
||||
if cache.has_key(full_key):
|
||||
cache.touch(full_key)
|
||||
return cache.get(full_key)
|
||||
|
||||
return check_non_default(gravatar_url)
|
||||
try:
|
||||
# Since we specify a default of 404, do a HEAD request
|
||||
# (HEAD since we don't need the body)
|
||||
# so if that returns a 404, move onto the next mode
|
||||
res = get_http_session().head(gravatar_url, timeout=5)
|
||||
if res.status_code == 404:
|
||||
cache.set(full_key, None)
|
||||
return None
|
||||
res.raise_for_status()
|
||||
except RequestException:
|
||||
return gravatar_url
|
||||
cache.set(full_key, gravatar_url)
|
||||
return gravatar_url
|
||||
|
||||
|
||||
def generate_colors(text: str) -> tuple[str, str]:
|
||||
@ -83,10 +87,10 @@ def generate_colors(text: str) -> tuple[str, str]:
|
||||
return bg_hex, text_hex
|
||||
|
||||
|
||||
@cache
|
||||
@funccache
|
||||
# pylint: disable=too-many-arguments,too-many-locals
|
||||
def generate_avatar_from_name(
|
||||
user: "User",
|
||||
name: str,
|
||||
length: int = 2,
|
||||
size: int = 64,
|
||||
rounded: bool = False,
|
||||
@ -98,8 +102,6 @@ def generate_avatar_from_name(
|
||||
|
||||
Inspired from: https://github.com/LasseRafn/ui-avatars
|
||||
"""
|
||||
name = user.name if user.name != "" else "a k"
|
||||
|
||||
name_parts = name.split()
|
||||
# Only abbreviate first and last name
|
||||
if len(name_parts) > 2:
|
||||
@ -152,7 +154,7 @@ def generate_avatar_from_name(
|
||||
|
||||
def avatar_mode_generated(user: "User", mode: str) -> Optional[str]:
|
||||
"""Wrapper that converts generated avatar to base64 svg"""
|
||||
svg = generate_avatar_from_name(user)
|
||||
svg = generate_avatar_from_name(user.name if user.name.strip() != "" else "a k")
|
||||
return f"data:image/svg+xml;base64,{b64encode(svg.encode('utf-8')).decode('utf-8')}"
|
||||
|
||||
|
||||
|
@ -1,9 +1,11 @@
|
||||
"""authentik expression policy evaluator"""
|
||||
import re
|
||||
import socket
|
||||
from ipaddress import ip_address, ip_network
|
||||
from textwrap import indent
|
||||
from typing import Any, Iterable, Optional
|
||||
|
||||
from cachetools import TLRUCache, cached
|
||||
from django.core.exceptions import FieldError
|
||||
from django_otp import devices_for_user
|
||||
from rest_framework.serializers import ValidationError
|
||||
@ -41,6 +43,8 @@ class BaseEvaluator:
|
||||
"ak_is_group_member": BaseEvaluator.expr_is_group_member,
|
||||
"ak_user_by": BaseEvaluator.expr_user_by,
|
||||
"ak_user_has_authenticator": BaseEvaluator.expr_func_user_has_authenticator,
|
||||
"resolve_dns": BaseEvaluator.expr_resolve_dns,
|
||||
"reverse_dns": BaseEvaluator.expr_reverse_dns,
|
||||
"ak_create_event": self.expr_event_create,
|
||||
"ak_logger": get_logger(self._filename).bind(),
|
||||
"requests": get_http_session(),
|
||||
@ -49,6 +53,39 @@ class BaseEvaluator:
|
||||
}
|
||||
self._context = {}
|
||||
|
||||
@cached(cache=TLRUCache(maxsize=32, ttu=lambda key, value, now: now + 180))
|
||||
@staticmethod
|
||||
def expr_resolve_dns(host: str, ip_version: Optional[int] = None) -> list[str]:
|
||||
"""Resolve host to a list of IPv4 and/or IPv6 addresses."""
|
||||
# Although it seems to be fine (raising OSError), docs warn
|
||||
# against passing `None` for both the host and the port
|
||||
# https://docs.python.org/3/library/socket.html#socket.getaddrinfo
|
||||
host = host or ""
|
||||
|
||||
ip_list = []
|
||||
|
||||
family = 0
|
||||
if ip_version == 4:
|
||||
family = socket.AF_INET
|
||||
if ip_version == 6:
|
||||
family = socket.AF_INET6
|
||||
|
||||
try:
|
||||
for ip_addr in socket.getaddrinfo(host, None, family=family):
|
||||
ip_list.append(str(ip_addr[4][0]))
|
||||
except OSError:
|
||||
pass
|
||||
return list(set(ip_list))
|
||||
|
||||
@cached(cache=TLRUCache(maxsize=32, ttu=lambda key, value, now: now + 180))
|
||||
@staticmethod
|
||||
def expr_reverse_dns(ip_addr: str) -> str:
|
||||
"""Perform a reverse DNS lookup."""
|
||||
try:
|
||||
return socket.getfqdn(ip_addr)
|
||||
except OSError:
|
||||
return ip_addr
|
||||
|
||||
@staticmethod
|
||||
def expr_flatten(value: list[Any] | Any) -> Optional[Any]:
|
||||
"""Flatten `value` if its a list"""
|
||||
|
58
authentik/lib/migrations.py
Normal file
58
authentik/lib/migrations.py
Normal file
@ -0,0 +1,58 @@
|
||||
"""Migration helpers"""
|
||||
from typing import Iterable
|
||||
|
||||
from django.apps.registry import Apps
|
||||
from django.db.backends.base.schema import BaseDatabaseSchemaEditor
|
||||
|
||||
|
||||
def fallback_names(app: str, model: str, field: str):
|
||||
"""Factory function that checks all instances of `app`.`model` instance's `field`
|
||||
to prevent any duplicates"""
|
||||
|
||||
def migrator(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
|
||||
db_alias = schema_editor.connection.alias
|
||||
|
||||
klass = apps.get_model(app, model)
|
||||
seen_names = []
|
||||
for obj in klass.objects.using(db_alias).all():
|
||||
value = getattr(obj, field)
|
||||
if value not in seen_names:
|
||||
seen_names.append(value)
|
||||
continue
|
||||
new_value = value + "_2"
|
||||
setattr(obj, field, new_value)
|
||||
obj.save()
|
||||
|
||||
return migrator
|
||||
|
||||
|
||||
def progress_bar(iterable: Iterable):
|
||||
"""Call in a loop to create terminal progress bar
|
||||
https://stackoverflow.com/questions/3173320/text-progress-bar-in-the-console"""
|
||||
|
||||
prefix = "Writing: "
|
||||
suffix = " finished"
|
||||
decimals = 1
|
||||
length = 100
|
||||
fill = "█"
|
||||
print_end = "\r"
|
||||
|
||||
total = len(iterable)
|
||||
if total < 1:
|
||||
return
|
||||
|
||||
def print_progress_bar(iteration):
|
||||
"""Progress Bar Printing Function"""
|
||||
percent = ("{0:." + str(decimals) + "f}").format(100 * (iteration / float(total)))
|
||||
filled_length = int(length * iteration // total)
|
||||
bar = fill * filled_length + "-" * (length - filled_length)
|
||||
print(f"\r{prefix} |{bar}| {percent}% {suffix}", end=print_end)
|
||||
|
||||
# Initial Call
|
||||
print_progress_bar(0)
|
||||
# Update Progress Bar
|
||||
for i, item in enumerate(iterable):
|
||||
yield item
|
||||
print_progress_bar(i + 1)
|
||||
# Print New Line on Complete
|
||||
print()
|
@ -74,3 +74,21 @@ class DomainlessURLValidator(URLValidator):
|
||||
if scheme not in self.schemes:
|
||||
value = "default" + value
|
||||
super().__call__(value)
|
||||
|
||||
|
||||
class DomainlessFormattedURLValidator(DomainlessURLValidator):
|
||||
"""URL validator which allows for python format strings"""
|
||||
|
||||
def __init__(self, *args, **kwargs) -> None:
|
||||
super().__init__(*args, **kwargs)
|
||||
self.host_re = r"([%\(\)a-zA-Z])+" + self.domain_re + self.domain_re
|
||||
self.regex = _lazy_re_compile(
|
||||
r"^(?:[a-z0-9.+-]*)://" # scheme is validated separately
|
||||
r"(?:[^\s:@/]+(?::[^\s:@/]*)?@)?" # user:pass authentication
|
||||
r"(?:" + self.ipv4_re + "|" + self.ipv6_re + "|" + self.host_re + ")"
|
||||
r"(?::\d{2,5})?" # port
|
||||
r"(?:[/?#][^\s]*)?" # resource path
|
||||
r"\Z",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
self.schemes = ["http", "https", "blank"] + list(self.schemes)
|
||||
|
@ -4,7 +4,6 @@ from typing import Any, Optional
|
||||
|
||||
from billiard.exceptions import SoftTimeLimitExceeded, WorkerLostError
|
||||
from celery.exceptions import CeleryError
|
||||
from channels.middleware import BaseMiddleware
|
||||
from channels_redis.core import ChannelFull
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ImproperlyConfigured, SuspiciousOperation, ValidationError
|
||||
@ -17,37 +16,24 @@ from ldap3.core.exceptions import LDAPException
|
||||
from redis.exceptions import ConnectionError as RedisConnectionError
|
||||
from redis.exceptions import RedisError, ResponseError
|
||||
from rest_framework.exceptions import APIException
|
||||
from sentry_sdk import HttpTransport, Hub
|
||||
from sentry_sdk import HttpTransport
|
||||
from sentry_sdk import init as sentry_sdk_init
|
||||
from sentry_sdk.api import set_tag
|
||||
from sentry_sdk.integrations.celery import CeleryIntegration
|
||||
from sentry_sdk.integrations.django import DjangoIntegration
|
||||
from sentry_sdk.integrations.redis import RedisIntegration
|
||||
from sentry_sdk.integrations.threading import ThreadingIntegration
|
||||
from sentry_sdk.tracing import Transaction
|
||||
from structlog.stdlib import get_logger
|
||||
from websockets.exceptions import WebSocketException
|
||||
|
||||
from authentik import __version__, get_build_hash
|
||||
from authentik.lib.config import CONFIG
|
||||
from authentik.lib.utils.http import authentik_user_agent
|
||||
from authentik.lib.utils.reflection import class_to_path, get_env
|
||||
from authentik.lib.utils.reflection import get_env
|
||||
|
||||
LOGGER = get_logger()
|
||||
|
||||
|
||||
class SentryWSMiddleware(BaseMiddleware):
|
||||
"""Sentry Websocket middleweare to set the transaction name based on
|
||||
consumer class path"""
|
||||
|
||||
async def __call__(self, scope, receive, send):
|
||||
transaction: Optional[Transaction] = Hub.current.scope.transaction
|
||||
class_path = class_to_path(self.inner.consumer_class)
|
||||
if transaction:
|
||||
transaction.name = class_path
|
||||
return await self.inner(scope, receive, send)
|
||||
|
||||
|
||||
class SentryIgnoredException(Exception):
|
||||
"""Base Class for all errors that are suppressed, and not sent to sentry."""
|
||||
|
||||
@ -94,9 +80,12 @@ def sentry_init(**sentry_init_kwargs):
|
||||
def traces_sampler(sampling_context: dict) -> float:
|
||||
"""Custom sampler to ignore certain routes"""
|
||||
path = sampling_context.get("asgi_scope", {}).get("path", "")
|
||||
_type = sampling_context.get("asgi_scope", {}).get("type", "")
|
||||
# Ignore all healthcheck routes
|
||||
if path.startswith("/-/health") or path.startswith("/-/metrics"):
|
||||
return 0
|
||||
if _type == "websocket":
|
||||
return 0
|
||||
return float(CONFIG.y("error_reporting.sample_rate", 0.1))
|
||||
|
||||
|
||||
|
@ -9,4 +9,4 @@ def get_lxml_parser():
|
||||
|
||||
def lxml_from_string(text: str):
|
||||
"""Wrapper around fromstring"""
|
||||
return fromstring(text, parser=get_lxml_parser())
|
||||
return fromstring(text, parser=get_lxml_parser()) # nosec
|
||||
|
@ -16,7 +16,6 @@ from authentik.outposts.controllers.k8s.triggers import NeedsRecreate, NeedsUpda
|
||||
if TYPE_CHECKING:
|
||||
from authentik.outposts.controllers.kubernetes import KubernetesController
|
||||
|
||||
# pylint: disable=invalid-name
|
||||
T = TypeVar("T", V1Pod, V1Deployment)
|
||||
|
||||
|
||||
@ -56,6 +55,7 @@ class KubernetesObjectReconciler(Generic[T]):
|
||||
}
|
||||
).lower()
|
||||
|
||||
# pylint: disable=invalid-name
|
||||
def up(self):
|
||||
"""Create object if it doesn't exist, update if needed or recreate if needed."""
|
||||
current = None
|
||||
|
@ -0,0 +1,28 @@
|
||||
# Generated by Django 4.1.7 on 2023-03-07 13:41
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
from authentik.lib.migrations import fallback_names
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("authentik_outposts", "0018_kubernetesserviceconnection_verify_ssl"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(fallback_names("authentik_outposts", "outpost", "name")),
|
||||
migrations.RunPython(
|
||||
fallback_names("authentik_outposts", "outpostserviceconnection", "name")
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="outpost",
|
||||
name="name",
|
||||
field=models.TextField(unique=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="outpostserviceconnection",
|
||||
name="name",
|
||||
field=models.TextField(unique=True),
|
||||
),
|
||||
]
|
@ -113,7 +113,7 @@ class OutpostServiceConnection(models.Model):
|
||||
"""Connection details for an Outpost Controller, like Docker or Kubernetes"""
|
||||
|
||||
uuid = models.UUIDField(default=uuid4, editable=False, primary_key=True)
|
||||
name = models.TextField()
|
||||
name = models.TextField(unique=True)
|
||||
|
||||
local = models.BooleanField(
|
||||
default=False,
|
||||
@ -239,7 +239,7 @@ class Outpost(SerializerModel, ManagedModel):
|
||||
"""Outpost instance which manages a service user and token"""
|
||||
|
||||
uuid = models.UUIDField(default=uuid4, editable=False, primary_key=True)
|
||||
name = models.TextField()
|
||||
name = models.TextField(unique=True)
|
||||
|
||||
type = models.TextField(choices=OutpostType.choices, default=OutpostType.PROXY)
|
||||
service_connection = InheritanceForeignKey(
|
||||
|
@ -7,7 +7,6 @@ from urllib.parse import urlparse
|
||||
|
||||
import yaml
|
||||
from asgiref.sync import async_to_sync
|
||||
from channels.layers import get_channel_layer
|
||||
from django.core.cache import cache
|
||||
from django.db import DatabaseError, InternalError, ProgrammingError
|
||||
from django.db.models.base import Model
|
||||
@ -43,6 +42,7 @@ from authentik.providers.ldap.controllers.kubernetes import LDAPKubernetesContro
|
||||
from authentik.providers.proxy.controllers.docker import ProxyDockerController
|
||||
from authentik.providers.proxy.controllers.kubernetes import ProxyKubernetesController
|
||||
from authentik.root.celery import CELERY_APP
|
||||
from authentik.root.messages.storage import closing_send
|
||||
|
||||
LOGGER = get_logger()
|
||||
CACHE_KEY_OUTPOST_DOWN = "outpost_teardown_%s"
|
||||
@ -217,26 +217,23 @@ def outpost_post_save(model_class: str, model_pk: Any):
|
||||
def outpost_send_update(model_instace: Model):
|
||||
"""Send outpost update to all registered outposts, regardless to which authentik
|
||||
instance they are connected"""
|
||||
channel_layer = get_channel_layer()
|
||||
if isinstance(model_instace, OutpostModel):
|
||||
for outpost in model_instace.outpost_set.all():
|
||||
_outpost_single_update(outpost, channel_layer)
|
||||
_outpost_single_update(outpost)
|
||||
elif isinstance(model_instace, Outpost):
|
||||
_outpost_single_update(model_instace, channel_layer)
|
||||
_outpost_single_update(model_instace)
|
||||
|
||||
|
||||
def _outpost_single_update(outpost: Outpost, layer=None):
|
||||
def _outpost_single_update(outpost: Outpost):
|
||||
"""Update outpost instances connected to a single outpost"""
|
||||
# Ensure token again, because this function is called when anything related to an
|
||||
# OutpostModel is saved, so we can be sure permissions are right
|
||||
_ = outpost.token
|
||||
outpost.build_user_permissions(outpost.user)
|
||||
if not layer: # pragma: no cover
|
||||
layer = get_channel_layer()
|
||||
for state in OutpostState.for_outpost(outpost):
|
||||
for channel in state.channel_ids:
|
||||
LOGGER.debug("sending update", channel=channel, instance=state.uid, outpost=outpost)
|
||||
async_to_sync(layer.send)(channel, {"type": "event.update"})
|
||||
async_to_sync(closing_send)(channel, {"type": "event.update"})
|
||||
|
||||
|
||||
@CELERY_APP.task()
|
||||
|
@ -4,6 +4,7 @@ from rest_framework.test import APITestCase
|
||||
|
||||
from authentik.core.models import PropertyMapping
|
||||
from authentik.core.tests.utils import create_test_admin_user, create_test_flow
|
||||
from authentik.lib.generators import generate_id
|
||||
from authentik.outposts.api.outposts import OutpostSerializer
|
||||
from authentik.outposts.models import OutpostType, default_outpost_config
|
||||
from authentik.providers.ldap.models import LDAPProvider
|
||||
@ -16,7 +17,7 @@ class TestOutpostServiceConnectionsAPI(APITestCase):
|
||||
def setUp(self) -> None:
|
||||
super().setUp()
|
||||
self.mapping = PropertyMapping.objects.create(
|
||||
name="dummy", expression="""return {'foo': 'bar'}"""
|
||||
name=generate_id(), expression="""return {'foo': 'bar'}"""
|
||||
)
|
||||
self.user = create_test_admin_user()
|
||||
self.client.force_login(self.user)
|
||||
@ -25,12 +26,12 @@ class TestOutpostServiceConnectionsAPI(APITestCase):
|
||||
"""Test Outpost validation"""
|
||||
valid = OutpostSerializer(
|
||||
data={
|
||||
"name": "foo",
|
||||
"name": generate_id(),
|
||||
"type": OutpostType.PROXY,
|
||||
"config": default_outpost_config(),
|
||||
"providers": [
|
||||
ProxyProvider.objects.create(
|
||||
name="test", authorization_flow=create_test_flow()
|
||||
name=generate_id(), authorization_flow=create_test_flow()
|
||||
).pk
|
||||
],
|
||||
}
|
||||
@ -38,12 +39,12 @@ class TestOutpostServiceConnectionsAPI(APITestCase):
|
||||
self.assertTrue(valid.is_valid())
|
||||
invalid = OutpostSerializer(
|
||||
data={
|
||||
"name": "foo",
|
||||
"name": generate_id(),
|
||||
"type": OutpostType.PROXY,
|
||||
"config": default_outpost_config(),
|
||||
"providers": [
|
||||
LDAPProvider.objects.create(
|
||||
name="test", authorization_flow=create_test_flow()
|
||||
name=generate_id(), authorization_flow=create_test_flow()
|
||||
).pk
|
||||
],
|
||||
}
|
||||
@ -60,15 +61,19 @@ class TestOutpostServiceConnectionsAPI(APITestCase):
|
||||
|
||||
def test_outpost_config(self):
|
||||
"""Test Outpost's config field"""
|
||||
provider = ProxyProvider.objects.create(name="test", authorization_flow=create_test_flow())
|
||||
invalid = OutpostSerializer(data={"name": "foo", "providers": [provider.pk], "config": ""})
|
||||
provider = ProxyProvider.objects.create(
|
||||
name=generate_id(), authorization_flow=create_test_flow()
|
||||
)
|
||||
invalid = OutpostSerializer(
|
||||
data={"name": generate_id(), "providers": [provider.pk], "config": ""}
|
||||
)
|
||||
self.assertFalse(invalid.is_valid())
|
||||
self.assertIn("config", invalid.errors)
|
||||
valid = OutpostSerializer(
|
||||
data={
|
||||
"name": "foo",
|
||||
"name": generate_id(),
|
||||
"providers": [provider.pk],
|
||||
"config": default_outpost_config("foo"),
|
||||
"config": default_outpost_config(generate_id()),
|
||||
"type": OutpostType.PROXY,
|
||||
}
|
||||
)
|
||||
|
@ -7,11 +7,6 @@ GAUGE_POLICIES_CACHED = Gauge(
|
||||
"authentik_policies_cached",
|
||||
"Cached Policies",
|
||||
)
|
||||
HIST_POLICIES_BUILD_TIME = Histogram(
|
||||
"authentik_policies_build_time",
|
||||
"Execution times complete policy result to an object",
|
||||
["object_pk", "object_type"],
|
||||
)
|
||||
|
||||
HIST_POLICIES_EXECUTION_TIME = Histogram(
|
||||
"authentik_policies_execution_time",
|
||||
|
@ -10,7 +10,6 @@ from sentry_sdk.tracing import Span
|
||||
from structlog.stdlib import BoundLogger, get_logger
|
||||
|
||||
from authentik.core.models import User
|
||||
from authentik.policies.apps import HIST_POLICIES_BUILD_TIME
|
||||
from authentik.policies.exceptions import PolicyEngineException
|
||||
from authentik.policies.models import Policy, PolicyBinding, PolicyBindingModel, PolicyEngineMode
|
||||
from authentik.policies.process import PolicyProcess, cache_key
|
||||
@ -86,10 +85,6 @@ class PolicyEngine:
|
||||
op="authentik.policy.engine.build",
|
||||
description=self.__pbm,
|
||||
) as span,
|
||||
HIST_POLICIES_BUILD_TIME.labels(
|
||||
object_pk=str(self.__pbm.pk),
|
||||
object_type=f"{self.__pbm._meta.app_label}.{self.__pbm._meta.model_name}",
|
||||
).time(),
|
||||
):
|
||||
span: Span
|
||||
span.set_data("pbm", self.__pbm)
|
||||
|
20
authentik/policies/migrations/0010_alter_policy_name.py
Normal file
20
authentik/policies/migrations/0010_alter_policy_name.py
Normal file
@ -0,0 +1,20 @@
|
||||
# Generated by Django 4.1.7 on 2023-03-07 13:41
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
from authentik.lib.migrations import fallback_names
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("authentik_policies", "0009_alter_policy_name"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(fallback_names("authentik_policies", "policy", "name")),
|
||||
migrations.AlterField(
|
||||
model_name="policy",
|
||||
name="name",
|
||||
field=models.TextField(unique=True),
|
||||
),
|
||||
]
|
@ -158,7 +158,7 @@ class Policy(SerializerModel, CreatedUpdatedModel):
|
||||
|
||||
policy_uuid = models.UUIDField(primary_key=True, editable=False, default=uuid4)
|
||||
|
||||
name = models.TextField()
|
||||
name = models.TextField(unique=True)
|
||||
|
||||
execution_logging = models.BooleanField(
|
||||
default=False,
|
||||
|
@ -2,7 +2,8 @@
|
||||
from django.core.cache import cache
|
||||
from django.test import TestCase
|
||||
|
||||
from authentik.core.models import User
|
||||
from authentik.core.tests.utils import create_test_admin_user
|
||||
from authentik.lib.generators import generate_id
|
||||
from authentik.policies.dummy.models import DummyPolicy
|
||||
from authentik.policies.engine import PolicyEngine
|
||||
from authentik.policies.exceptions import PolicyEngineException
|
||||
@ -17,11 +18,17 @@ class TestPolicyEngine(TestCase):
|
||||
|
||||
def setUp(self):
|
||||
clear_policy_cache()
|
||||
self.user = User.objects.create_user(username="policyuser")
|
||||
self.policy_false = DummyPolicy.objects.create(result=False, wait_min=0, wait_max=1)
|
||||
self.policy_true = DummyPolicy.objects.create(result=True, wait_min=0, wait_max=1)
|
||||
self.policy_wrong_type = Policy.objects.create(name="wrong_type")
|
||||
self.policy_raises = ExpressionPolicy.objects.create(name="raises", expression="{{ 0/0 }}")
|
||||
self.user = create_test_admin_user()
|
||||
self.policy_false = DummyPolicy.objects.create(
|
||||
name=generate_id(), result=False, wait_min=0, wait_max=1
|
||||
)
|
||||
self.policy_true = DummyPolicy.objects.create(
|
||||
name=generate_id(), result=True, wait_min=0, wait_max=1
|
||||
)
|
||||
self.policy_wrong_type = Policy.objects.create(name=generate_id())
|
||||
self.policy_raises = ExpressionPolicy.objects.create(
|
||||
name=generate_id(), expression="{{ 0/0 }}"
|
||||
)
|
||||
|
||||
def test_engine_empty(self):
|
||||
"""Ensure empty policy list passes"""
|
||||
|
@ -1,5 +1,6 @@
|
||||
"""OAuth2Provider API Views"""
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from drf_spectacular.utils import OpenApiResponse, extend_schema
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.fields import CharField
|
||||
@ -153,6 +154,7 @@ class OAuth2ProviderViewSet(UsedByMixin, ModelViewSet):
|
||||
user=request.user,
|
||||
provider=provider,
|
||||
_scope=" ".join(scope_names),
|
||||
auth_time=timezone.now(),
|
||||
),
|
||||
request,
|
||||
)
|
||||
|
@ -141,16 +141,21 @@ class AuthorizeError(OAuth2Error):
|
||||
),
|
||||
}
|
||||
|
||||
# pylint: disable=too-many-arguments
|
||||
def __init__(
|
||||
self,
|
||||
redirect_uri: str,
|
||||
error: str,
|
||||
grant_type: str,
|
||||
state: str,
|
||||
description: Optional[str] = None,
|
||||
):
|
||||
super().__init__()
|
||||
self.error = error
|
||||
self.description = self.errors[error]
|
||||
if description:
|
||||
self.description = description
|
||||
else:
|
||||
self.description = self.errors[error]
|
||||
self.redirect_uri = redirect_uri
|
||||
self.grant_type = grant_type
|
||||
self.state = state
|
||||
@ -169,10 +174,12 @@ class AuthorizeError(OAuth2Error):
|
||||
|
||||
# See:
|
||||
# http://openid.net/specs/openid-connect-core-1_0.html#ImplicitAuthError
|
||||
hash_or_question = "#" if self.grant_type == GrantTypes.IMPLICIT else "?"
|
||||
fragment_or_query = (
|
||||
"#" if self.grant_type in [GrantTypes.IMPLICIT, GrantTypes.HYBRID] else "?"
|
||||
)
|
||||
|
||||
uri = (
|
||||
f"{self.redirect_uri}{hash_or_question}error="
|
||||
f"{self.redirect_uri}{fragment_or_query}error="
|
||||
f"{self.error}&error_description={description}"
|
||||
)
|
||||
|
||||
|
@ -110,12 +110,11 @@ class IDToken:
|
||||
# Convert datetimes into timestamps.
|
||||
now = timezone.now()
|
||||
id_token.iat = int(now.timestamp())
|
||||
id_token.auth_time = int(token.auth_time.timestamp())
|
||||
|
||||
# We use the timestamp of the user's last successful login (EventAction.LOGIN) for auth_time
|
||||
auth_event = get_login_event(request)
|
||||
if auth_event:
|
||||
auth_time = auth_event.created
|
||||
id_token.auth_time = int(auth_time.timestamp())
|
||||
# Also check which method was used for authentication
|
||||
method = auth_event.context.get(PLAN_CONTEXT_METHOD, "")
|
||||
method_args = auth_event.context.get(PLAN_CONTEXT_METHOD_ARGS, {})
|
||||
|
@ -0,0 +1,40 @@
|
||||
# Generated by Django 4.1.7 on 2023-02-22 22:23
|
||||
|
||||
import django.utils.timezone
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("authentik_providers_oauth2", "0014_alter_refreshtoken_options_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="accesstoken",
|
||||
name="auth_time",
|
||||
field=models.DateTimeField(
|
||||
default=django.utils.timezone.now,
|
||||
verbose_name="Authentication time",
|
||||
),
|
||||
preserve_default=False,
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="authorizationcode",
|
||||
name="auth_time",
|
||||
field=models.DateTimeField(
|
||||
default=django.utils.timezone.now,
|
||||
verbose_name="Authentication time",
|
||||
),
|
||||
preserve_default=False,
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="refreshtoken",
|
||||
name="auth_time",
|
||||
field=models.DateTimeField(
|
||||
default=django.utils.timezone.now,
|
||||
verbose_name="Authentication time",
|
||||
),
|
||||
preserve_default=False,
|
||||
),
|
||||
]
|
@ -226,7 +226,7 @@ class OAuth2Provider(Provider):
|
||||
def get_issuer(self, request: HttpRequest) -> Optional[str]:
|
||||
"""Get issuer, based on request"""
|
||||
if self.issuer_mode == IssuerMode.GLOBAL:
|
||||
return request.build_absolute_uri("/")
|
||||
return request.build_absolute_uri(reverse("authentik_core:root-redirect"))
|
||||
try:
|
||||
url = reverse(
|
||||
"authentik_providers_oauth2:provider-root",
|
||||
@ -282,6 +282,7 @@ class BaseGrantModel(models.Model):
|
||||
user = models.ForeignKey(User, verbose_name=_("User"), on_delete=models.CASCADE)
|
||||
revoked = models.BooleanField(default=False)
|
||||
_scope = models.TextField(default="", verbose_name=_("Scopes"))
|
||||
auth_time = models.DateTimeField(verbose_name="Authentication time")
|
||||
|
||||
@property
|
||||
def scope(self) -> list[str]:
|
||||
|
@ -204,6 +204,7 @@ class TestAuthorize(OAuthTestCase):
|
||||
"redirect_uri": "http://local.invalid/Foo",
|
||||
"scope": "openid",
|
||||
"state": "foo",
|
||||
"nonce": generate_id(),
|
||||
},
|
||||
)
|
||||
self.assertEqual(
|
||||
@ -325,6 +326,7 @@ class TestAuthorize(OAuthTestCase):
|
||||
"state": state,
|
||||
"scope": "openid",
|
||||
"redirect_uri": "http://localhost",
|
||||
"nonce": generate_id(),
|
||||
},
|
||||
)
|
||||
response = self.client.get(
|
||||
@ -378,6 +380,7 @@ class TestAuthorize(OAuthTestCase):
|
||||
"state": state,
|
||||
"scope": "openid",
|
||||
"redirect_uri": "http://localhost",
|
||||
"nonce": generate_id(),
|
||||
},
|
||||
)
|
||||
response = self.client.get(
|
||||
|
@ -4,6 +4,7 @@ from base64 import b64encode
|
||||
from dataclasses import asdict
|
||||
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
|
||||
from authentik.core.models import Application
|
||||
from authentik.core.tests.utils import create_test_admin_user, create_test_cert, create_test_flow
|
||||
@ -41,6 +42,7 @@ class TesOAuth2Introspection(OAuthTestCase):
|
||||
provider=self.provider,
|
||||
user=self.user,
|
||||
token=generate_id(),
|
||||
auth_time=timezone.now(),
|
||||
_scope="openid user profile",
|
||||
_id_token=json.dumps(
|
||||
asdict(
|
||||
@ -72,6 +74,7 @@ class TesOAuth2Introspection(OAuthTestCase):
|
||||
provider=self.provider,
|
||||
user=self.user,
|
||||
token=generate_id(),
|
||||
auth_time=timezone.now(),
|
||||
_scope="openid user profile",
|
||||
_id_token=json.dumps(
|
||||
asdict(
|
||||
|
@ -4,6 +4,7 @@ from base64 import b64encode
|
||||
from dataclasses import asdict
|
||||
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
|
||||
from authentik.core.models import Application
|
||||
from authentik.core.tests.utils import create_test_admin_user, create_test_cert, create_test_flow
|
||||
@ -40,6 +41,7 @@ class TesOAuth2Revoke(OAuthTestCase):
|
||||
provider=self.provider,
|
||||
user=self.user,
|
||||
token=generate_id(),
|
||||
auth_time=timezone.now(),
|
||||
_scope="openid user profile",
|
||||
_id_token=json.dumps(
|
||||
asdict(
|
||||
@ -62,6 +64,7 @@ class TesOAuth2Revoke(OAuthTestCase):
|
||||
provider=self.provider,
|
||||
user=self.user,
|
||||
token=generate_id(),
|
||||
auth_time=timezone.now(),
|
||||
_scope="openid user profile",
|
||||
_id_token=json.dumps(
|
||||
asdict(
|
||||
|
@ -4,6 +4,7 @@ from json import dumps
|
||||
|
||||
from django.test import RequestFactory
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
|
||||
from authentik.core.models import Application
|
||||
from authentik.core.tests.utils import create_test_admin_user, create_test_flow
|
||||
@ -45,7 +46,9 @@ class TestToken(OAuthTestCase):
|
||||
)
|
||||
header = b64encode(f"{provider.client_id}:{provider.client_secret}".encode()).decode()
|
||||
user = create_test_admin_user()
|
||||
code = AuthorizationCode.objects.create(code="foobar", provider=provider, user=user)
|
||||
code = AuthorizationCode.objects.create(
|
||||
code="foobar", provider=provider, user=user, auth_time=timezone.now()
|
||||
)
|
||||
request = self.factory.post(
|
||||
"/",
|
||||
data={
|
||||
@ -99,6 +102,7 @@ class TestToken(OAuthTestCase):
|
||||
provider=provider,
|
||||
user=user,
|
||||
token=generate_id(),
|
||||
auth_time=timezone.now(),
|
||||
)
|
||||
request = self.factory.post(
|
||||
"/",
|
||||
@ -127,7 +131,9 @@ class TestToken(OAuthTestCase):
|
||||
self.app.save()
|
||||
header = b64encode(f"{provider.client_id}:{provider.client_secret}".encode()).decode()
|
||||
user = create_test_admin_user()
|
||||
code = AuthorizationCode.objects.create(code="foobar", provider=provider, user=user)
|
||||
code = AuthorizationCode.objects.create(
|
||||
code="foobar", provider=provider, user=user, auth_time=timezone.now()
|
||||
)
|
||||
response = self.client.post(
|
||||
reverse("authentik_providers_oauth2:token"),
|
||||
data={
|
||||
@ -173,6 +179,7 @@ class TestToken(OAuthTestCase):
|
||||
user=user,
|
||||
token=generate_id(),
|
||||
_id_token=dumps({}),
|
||||
auth_time=timezone.now(),
|
||||
)
|
||||
response = self.client.post(
|
||||
reverse("authentik_providers_oauth2:token"),
|
||||
@ -221,6 +228,7 @@ class TestToken(OAuthTestCase):
|
||||
user=user,
|
||||
token=generate_id(),
|
||||
_id_token=dumps({}),
|
||||
auth_time=timezone.now(),
|
||||
)
|
||||
response = self.client.post(
|
||||
reverse("authentik_providers_oauth2:token"),
|
||||
@ -271,6 +279,7 @@ class TestToken(OAuthTestCase):
|
||||
user=user,
|
||||
token=generate_id(),
|
||||
_id_token=dumps({}),
|
||||
auth_time=timezone.now(),
|
||||
)
|
||||
# Create initial refresh token
|
||||
response = self.client.post(
|
||||
|
@ -3,6 +3,7 @@ import json
|
||||
from dataclasses import asdict
|
||||
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
|
||||
from authentik.blueprints.tests import apply_blueprint
|
||||
from authentik.core.models import Application
|
||||
@ -37,6 +38,7 @@ class TestUserinfo(OAuthTestCase):
|
||||
provider=self.provider,
|
||||
user=self.user,
|
||||
token=generate_id(),
|
||||
auth_time=timezone.now(),
|
||||
_scope="openid user profile",
|
||||
_id_token=json.dumps(
|
||||
asdict(
|
||||
@ -56,7 +58,6 @@ class TestUserinfo(OAuthTestCase):
|
||||
{
|
||||
"name": self.user.name,
|
||||
"given_name": self.user.name,
|
||||
"family_name": "",
|
||||
"preferred_username": self.user.name,
|
||||
"nickname": self.user.name,
|
||||
"groups": [group.name for group in self.user.ak_groups.all()],
|
||||
@ -79,7 +80,6 @@ class TestUserinfo(OAuthTestCase):
|
||||
{
|
||||
"name": self.user.name,
|
||||
"given_name": self.user.name,
|
||||
"family_name": "",
|
||||
"preferred_username": self.user.name,
|
||||
"nickname": self.user.name,
|
||||
"groups": [group.name for group in self.user.ak_groups.all()],
|
||||
|
@ -42,7 +42,7 @@ urlpatterns = [
|
||||
path("<slug:application_slug>/jwks/", JWKSView.as_view(), name="jwks"),
|
||||
path(
|
||||
"<slug:application_slug>/",
|
||||
RedirectView.as_view(pattern_name="authentk_providers_oauth2:provider-info"),
|
||||
RedirectView.as_view(pattern_name="authentik_providers_oauth2:provider-info"),
|
||||
name="provider-root",
|
||||
),
|
||||
path(
|
||||
|
@ -146,9 +146,10 @@ def protected_resource_view(scopes: list[str]):
|
||||
LOGGER.warning("Revoked token was used", access_token=access_token)
|
||||
Event.new(
|
||||
action=EventAction.SUSPICIOUS_REQUEST,
|
||||
message="Revoked refresh token was used",
|
||||
token=access_token,
|
||||
).from_http(request)
|
||||
message="Revoked access token was used",
|
||||
token=token,
|
||||
provider=token.provider,
|
||||
).from_http(request, user=token.user)
|
||||
raise BearerTokenError("invalid_token")
|
||||
|
||||
if not set(scopes).issubset(set(token.scope)):
|
||||
|
@ -17,13 +17,14 @@ from structlog.stdlib import get_logger
|
||||
|
||||
from authentik.core.models import Application
|
||||
from authentik.events.models import Event, EventAction
|
||||
from authentik.events.utils import get_user
|
||||
from authentik.events.signals import get_login_event
|
||||
from authentik.flows.challenge import (
|
||||
PLAN_CONTEXT_TITLE,
|
||||
AutosubmitChallenge,
|
||||
ChallengeTypes,
|
||||
HttpChallengeResponse,
|
||||
)
|
||||
from authentik.flows.exceptions import FlowNonApplicableException
|
||||
from authentik.flows.models import in_memory_stage
|
||||
from authentik.flows.planner import PLAN_CONTEXT_APPLICATION, PLAN_CONTEXT_SSO, FlowPlanner
|
||||
from authentik.flows.stage import StageView
|
||||
@ -64,12 +65,11 @@ from authentik.stages.consent.stage import (
|
||||
PLAN_CONTEXT_CONSENT_PERMISSIONS,
|
||||
ConsentStageView,
|
||||
)
|
||||
from authentik.stages.user_login.stage import USER_LOGIN_AUTHENTICATED
|
||||
|
||||
LOGGER = get_logger()
|
||||
|
||||
PLAN_CONTEXT_PARAMS = "params"
|
||||
SESSION_KEY_NEEDS_LOGIN = "authentik/providers/oauth2/needs_login"
|
||||
SESSION_KEY_LAST_LOGIN_UID = "authentik/providers/oauth2/last_login_uid"
|
||||
|
||||
ALLOWED_PROMPT_PARAMS = {PROMPT_NONE, PROMPT_CONSENT, PROMPT_LOGIN}
|
||||
|
||||
@ -158,13 +158,14 @@ class OAuthAuthorizationParams:
|
||||
request=query_dict.get("request", None),
|
||||
max_age=int(max_age) if max_age else None,
|
||||
code_challenge=query_dict.get("code_challenge"),
|
||||
code_challenge_method=query_dict.get("code_challenge_method"),
|
||||
code_challenge_method=query_dict.get("code_challenge_method", "plain"),
|
||||
)
|
||||
|
||||
def __post_init__(self):
|
||||
try:
|
||||
self.provider: OAuth2Provider = OAuth2Provider.objects.get(client_id=self.client_id)
|
||||
except OAuth2Provider.DoesNotExist:
|
||||
self.provider: OAuth2Provider = OAuth2Provider.objects.filter(
|
||||
client_id=self.client_id
|
||||
).first()
|
||||
if not self.provider:
|
||||
LOGGER.warning("Invalid client identifier", client_id=self.client_id)
|
||||
raise ClientIdError(client_id=self.client_id)
|
||||
self.check_redirect_uri()
|
||||
@ -234,40 +235,54 @@ class OAuthAuthorizationParams:
|
||||
|
||||
def check_nonce(self):
|
||||
"""Nonce parameter validation."""
|
||||
# https://openid.net/specs/openid-connect-core-1_0.html#ImplicitIDTValidation
|
||||
# Nonce is only required for Implicit flows
|
||||
if self.grant_type != GrantTypes.IMPLICIT:
|
||||
# nonce is required for all flows that return an id_token from the authorization endpoint,
|
||||
# see https://openid.net/specs/openid-connect-core-1_0.html#ImplicitAuthRequest or
|
||||
# https://openid.net/specs/openid-connect-core-1_0.html#HybridIDToken and
|
||||
# https://bitbucket.org/openid/connect/issues/972/nonce-requirement-in-hybrid-auth-request
|
||||
if self.response_type not in [
|
||||
ResponseTypes.ID_TOKEN,
|
||||
ResponseTypes.ID_TOKEN_TOKEN,
|
||||
ResponseTypes.CODE_ID_TOKEN,
|
||||
ResponseTypes.CODE_ID_TOKEN_TOKEN,
|
||||
]:
|
||||
return
|
||||
if SCOPE_OPENID not in self.scope:
|
||||
return
|
||||
if not self.nonce:
|
||||
self.nonce = self.state
|
||||
LOGGER.warning("Using state as nonce for OpenID Request")
|
||||
if not self.nonce:
|
||||
if SCOPE_OPENID in self.scope:
|
||||
LOGGER.warning("Missing nonce for OpenID Request")
|
||||
raise AuthorizeError(
|
||||
self.redirect_uri, "invalid_request", self.grant_type, self.state
|
||||
)
|
||||
LOGGER.warning("Missing nonce for OpenID Request")
|
||||
raise AuthorizeError(self.redirect_uri, "invalid_request", self.grant_type, self.state)
|
||||
|
||||
def check_code_challenge(self):
|
||||
"""PKCE validation of the transformation method."""
|
||||
if self.code_challenge and self.code_challenge_method not in ["plain", "S256"]:
|
||||
raise AuthorizeError(self.redirect_uri, "invalid_request", self.grant_type, self.state)
|
||||
raise AuthorizeError(
|
||||
self.redirect_uri,
|
||||
"invalid_request",
|
||||
self.grant_type,
|
||||
self.state,
|
||||
f"Unsupported challenge method {self.code_challenge_method}",
|
||||
)
|
||||
|
||||
def create_code(self, request: HttpRequest) -> AuthorizationCode:
|
||||
"""Create an AuthorizationCode object for the request"""
|
||||
code = AuthorizationCode()
|
||||
code.user = request.user
|
||||
code.provider = self.provider
|
||||
auth_event = get_login_event(request)
|
||||
|
||||
code.code = uuid4().hex
|
||||
now = timezone.now()
|
||||
|
||||
code = AuthorizationCode(
|
||||
user=request.user,
|
||||
provider=self.provider,
|
||||
auth_time=auth_event.created if auth_event else now,
|
||||
code=uuid4().hex,
|
||||
expires=now + timedelta_from_string(self.provider.access_code_validity),
|
||||
scope=self.scope,
|
||||
nonce=self.nonce,
|
||||
)
|
||||
|
||||
if self.code_challenge and self.code_challenge_method:
|
||||
code.code_challenge = self.code_challenge
|
||||
code.code_challenge_method = self.code_challenge_method
|
||||
|
||||
code.expires = timezone.now() + timedelta_from_string(self.provider.access_code_validity)
|
||||
code.scope = self.scope
|
||||
code.nonce = self.nonce
|
||||
return code
|
||||
|
||||
|
||||
@ -302,7 +317,6 @@ class AuthorizationFlowInitView(PolicyAccessView):
|
||||
self.params.grant_type,
|
||||
self.params.state,
|
||||
)
|
||||
error.to_event(redirect_uri=error.redirect_uri).from_http(self.request)
|
||||
raise RequestValidationError(error.get_response(self.request))
|
||||
|
||||
def resolve_provider_application(self):
|
||||
@ -322,45 +336,60 @@ class AuthorizationFlowInitView(PolicyAccessView):
|
||||
|
||||
def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
|
||||
"""Start FlowPLanner, return to flow executor shell"""
|
||||
# Require a login event to be set, otherwise make the user re-login
|
||||
login_event = get_login_event(request)
|
||||
if not login_event:
|
||||
LOGGER.warning("request with no login event")
|
||||
return self.handle_no_permission()
|
||||
login_uid = str(login_event.pk)
|
||||
# After we've checked permissions, and the user has access, check if we need
|
||||
# to re-authenticate the user
|
||||
if self.params.max_age:
|
||||
current_age: timedelta = (
|
||||
timezone.now()
|
||||
- Event.objects.filter(action=EventAction.LOGIN, user=get_user(self.request.user))
|
||||
.latest("created")
|
||||
.created
|
||||
)
|
||||
# Attempt to check via the session's login event if set, otherwise we can't
|
||||
# check
|
||||
login_time = login_event.created
|
||||
current_age: timedelta = timezone.now() - login_time
|
||||
if current_age.total_seconds() > self.params.max_age:
|
||||
LOGGER.debug(
|
||||
"Triggering authentication as max_age requirement",
|
||||
max_age=self.params.max_age,
|
||||
ago=int(current_age.total_seconds()),
|
||||
)
|
||||
# Since we already need to re-authenticate the user, set the old login UID
|
||||
# in case this request has both max_age and prompt=login
|
||||
self.request.session[SESSION_KEY_LAST_LOGIN_UID] = login_uid
|
||||
return self.handle_no_permission()
|
||||
# If prompt=login, we need to re-authenticate the user regardless
|
||||
if (
|
||||
PROMPT_LOGIN in self.params.prompt
|
||||
and SESSION_KEY_NEEDS_LOGIN not in self.request.session
|
||||
# To prevent the user from having to double login when prompt is set to login
|
||||
# and the user has just signed it. This session variable is set in the UserLoginStage
|
||||
# and is (quite hackily) removed from the session in applications's API's List method
|
||||
and USER_LOGIN_AUTHENTICATED not in self.request.session
|
||||
):
|
||||
self.request.session[SESSION_KEY_NEEDS_LOGIN] = True
|
||||
return self.handle_no_permission()
|
||||
# Check if we're not already doing the re-authentication
|
||||
if PROMPT_LOGIN in self.params.prompt:
|
||||
# No previous login UID saved, so save the current uid and trigger
|
||||
# re-login, or previous login UID matches current one, so no re-login happened yet
|
||||
if (
|
||||
SESSION_KEY_LAST_LOGIN_UID not in self.request.session
|
||||
or login_uid == self.request.session[SESSION_KEY_LAST_LOGIN_UID]
|
||||
):
|
||||
self.request.session[SESSION_KEY_LAST_LOGIN_UID] = login_uid
|
||||
return self.handle_no_permission()
|
||||
scope_descriptions = UserInfoView().get_scope_descriptions(self.params.scope)
|
||||
# Regardless, we start the planner and return to it
|
||||
planner = FlowPlanner(self.provider.authorization_flow)
|
||||
planner.allow_empty_flows = True
|
||||
plan = planner.plan(
|
||||
self.request,
|
||||
{
|
||||
PLAN_CONTEXT_SSO: True,
|
||||
PLAN_CONTEXT_APPLICATION: self.application,
|
||||
# OAuth2 related params
|
||||
PLAN_CONTEXT_PARAMS: self.params,
|
||||
# Consent related params
|
||||
PLAN_CONTEXT_CONSENT_HEADER: _("You're about to sign into %(application)s.")
|
||||
% {"application": self.application.name},
|
||||
PLAN_CONTEXT_CONSENT_PERMISSIONS: scope_descriptions,
|
||||
},
|
||||
)
|
||||
try:
|
||||
plan = planner.plan(
|
||||
self.request,
|
||||
{
|
||||
PLAN_CONTEXT_SSO: True,
|
||||
PLAN_CONTEXT_APPLICATION: self.application,
|
||||
# OAuth2 related params
|
||||
PLAN_CONTEXT_PARAMS: self.params,
|
||||
# Consent related params
|
||||
PLAN_CONTEXT_CONSENT_HEADER: _("You're about to sign into %(application)s.")
|
||||
% {"application": self.application.name},
|
||||
PLAN_CONTEXT_CONSENT_PERMISSIONS: scope_descriptions,
|
||||
},
|
||||
)
|
||||
except FlowNonApplicableException:
|
||||
return self.handle_no_permission_authenticated()
|
||||
# OpenID clients can specify a `prompt` parameter, and if its set to consent we
|
||||
# need to inject a consent stage
|
||||
if PROMPT_CONSENT in self.params.prompt:
|
||||
@ -518,6 +547,7 @@ class OAuthFulfillmentStage(StageView):
|
||||
def create_implicit_response(self, code: Optional[AuthorizationCode]) -> dict:
|
||||
"""Create implicit response's URL Fragment dictionary"""
|
||||
query_fragment = {}
|
||||
auth_event = get_login_event(self.request)
|
||||
|
||||
now = timezone.now()
|
||||
access_token_expiry = now + timedelta_from_string(self.provider.access_token_validity)
|
||||
@ -526,6 +556,7 @@ class OAuthFulfillmentStage(StageView):
|
||||
scope=self.params.scope,
|
||||
expires=access_token_expiry,
|
||||
provider=self.provider,
|
||||
auth_time=auth_event.created if auth_event else now,
|
||||
)
|
||||
|
||||
id_token = IDToken.new(self.provider, token, self.request)
|
||||
@ -546,6 +577,8 @@ class OAuthFulfillmentStage(StageView):
|
||||
ResponseTypes.CODE_TOKEN,
|
||||
]:
|
||||
query_fragment["access_token"] = token.token
|
||||
# Get at_hash of the current token and update the id_token
|
||||
id_token.at_hash = token.at_hash
|
||||
|
||||
# Check if response_type must include id_token in the response.
|
||||
if self.params.response_type in [
|
||||
@ -554,8 +587,6 @@ class OAuthFulfillmentStage(StageView):
|
||||
ResponseTypes.CODE_ID_TOKEN,
|
||||
ResponseTypes.CODE_ID_TOKEN_TOKEN,
|
||||
]:
|
||||
# Get at_hash of the current token and update the id_token
|
||||
id_token.at_hash = token.at_hash
|
||||
query_fragment["id_token"] = self.provider.encode(id_token.to_dict())
|
||||
token._id_token = dumps(id_token.to_dict())
|
||||
|
||||
|
@ -10,6 +10,7 @@ from structlog.stdlib import get_logger
|
||||
|
||||
from authentik.core.models import Application
|
||||
from authentik.flows.challenge import Challenge, ChallengeResponse, ChallengeTypes
|
||||
from authentik.flows.exceptions import FlowNonApplicableException
|
||||
from authentik.flows.models import in_memory_stage
|
||||
from authentik.flows.planner import PLAN_CONTEXT_APPLICATION, PLAN_CONTEXT_SSO, FlowPlanner
|
||||
from authentik.flows.stage import ChallengeStageView
|
||||
@ -57,19 +58,23 @@ def validate_code(code: int, request: HttpRequest) -> Optional[HttpResponse]:
|
||||
scope_descriptions = UserInfoView().get_scope_descriptions(token.scope)
|
||||
planner = FlowPlanner(token.provider.authorization_flow)
|
||||
planner.allow_empty_flows = True
|
||||
plan = planner.plan(
|
||||
request,
|
||||
{
|
||||
PLAN_CONTEXT_SSO: True,
|
||||
PLAN_CONTEXT_APPLICATION: app,
|
||||
# OAuth2 related params
|
||||
PLAN_CONTEXT_DEVICE: token,
|
||||
# Consent related params
|
||||
PLAN_CONTEXT_CONSENT_HEADER: _("You're about to sign into %(application)s.")
|
||||
% {"application": app.name},
|
||||
PLAN_CONTEXT_CONSENT_PERMISSIONS: scope_descriptions,
|
||||
},
|
||||
)
|
||||
try:
|
||||
plan = planner.plan(
|
||||
request,
|
||||
{
|
||||
PLAN_CONTEXT_SSO: True,
|
||||
PLAN_CONTEXT_APPLICATION: app,
|
||||
# OAuth2 related params
|
||||
PLAN_CONTEXT_DEVICE: token,
|
||||
# Consent related params
|
||||
PLAN_CONTEXT_CONSENT_HEADER: _("You're about to sign into %(application)s.")
|
||||
% {"application": app.name},
|
||||
PLAN_CONTEXT_CONSENT_PERMISSIONS: scope_descriptions,
|
||||
},
|
||||
)
|
||||
except FlowNonApplicableException:
|
||||
LOGGER.warning("Flow not applicable to user")
|
||||
return None
|
||||
plan.insert_stage(in_memory_stage(OAuthDeviceCodeFinishStage))
|
||||
request.session[SESSION_KEY_PLAN] = plan
|
||||
return redirect_with_qs(
|
||||
@ -97,7 +102,11 @@ class DeviceEntryView(View):
|
||||
# Regardless, we start the planner and return to it
|
||||
planner = FlowPlanner(device_flow)
|
||||
planner.allow_empty_flows = True
|
||||
plan = planner.plan(self.request)
|
||||
try:
|
||||
plan = planner.plan(self.request)
|
||||
except FlowNonApplicableException:
|
||||
LOGGER.warning("Flow not applicable to user")
|
||||
return HttpResponse(status=404)
|
||||
plan.append_stage(in_memory_stage(OAuthDeviceCodeStage))
|
||||
|
||||
self.request.session[SESSION_KEY_PLAN] = plan
|
||||
|
@ -26,6 +26,7 @@ from authentik.core.models import (
|
||||
User,
|
||||
)
|
||||
from authentik.events.models import Event, EventAction
|
||||
from authentik.events.signals import get_login_event
|
||||
from authentik.flows.planner import PLAN_CONTEXT_APPLICATION
|
||||
from authentik.lib.utils.time import timedelta_from_string
|
||||
from authentik.policies.engine import PolicyEngine
|
||||
@ -262,8 +263,9 @@ class TokenParams:
|
||||
Event.new(
|
||||
action=EventAction.SUSPICIOUS_REQUEST,
|
||||
message="Revoked refresh token was used",
|
||||
token=raw_token,
|
||||
).from_http(request)
|
||||
token=self.refresh_token,
|
||||
provider=self.refresh_token.provider,
|
||||
).from_http(request, user=self.refresh_token.user)
|
||||
raise TokenError("invalid_grant")
|
||||
|
||||
def __post_init_client_credentials(self, request: HttpRequest):
|
||||
@ -478,6 +480,7 @@ class TokenView(View):
|
||||
expires=access_token_expiry,
|
||||
# Keep same scopes as previous token
|
||||
scope=self.params.authorization_code.scope,
|
||||
auth_time=self.params.authorization_code.auth_time,
|
||||
)
|
||||
access_token.id_token = IDToken.new(
|
||||
self.provider,
|
||||
@ -492,6 +495,7 @@ class TokenView(View):
|
||||
scope=self.params.authorization_code.scope,
|
||||
expires=refresh_token_expiry,
|
||||
provider=self.provider,
|
||||
auth_time=self.params.authorization_code.auth_time,
|
||||
)
|
||||
id_token = IDToken.new(
|
||||
self.provider,
|
||||
@ -520,7 +524,6 @@ class TokenView(View):
|
||||
unauthorized_scopes = set(self.params.scope) - set(self.params.refresh_token.scope)
|
||||
if unauthorized_scopes:
|
||||
raise TokenError("invalid_scope")
|
||||
|
||||
now = timezone.now()
|
||||
access_token_expiry = now + timedelta_from_string(self.provider.access_token_validity)
|
||||
access_token = AccessToken(
|
||||
@ -529,6 +532,7 @@ class TokenView(View):
|
||||
expires=access_token_expiry,
|
||||
# Keep same scopes as previous token
|
||||
scope=self.params.refresh_token.scope,
|
||||
auth_time=self.params.refresh_token.auth_time,
|
||||
)
|
||||
access_token.id_token = IDToken.new(
|
||||
self.provider,
|
||||
@ -543,6 +547,7 @@ class TokenView(View):
|
||||
scope=self.params.refresh_token.scope,
|
||||
expires=refresh_token_expiry,
|
||||
provider=self.provider,
|
||||
auth_time=self.params.refresh_token.auth_time,
|
||||
)
|
||||
id_token = IDToken.new(
|
||||
self.provider,
|
||||
@ -577,6 +582,7 @@ class TokenView(View):
|
||||
user=self.params.user,
|
||||
expires=access_token_expiry,
|
||||
scope=self.params.scope,
|
||||
auth_time=now,
|
||||
)
|
||||
access_token.id_token = IDToken.new(
|
||||
self.provider,
|
||||
@ -599,11 +605,13 @@ class TokenView(View):
|
||||
raise DeviceCodeError("authorization_pending")
|
||||
now = timezone.now()
|
||||
access_token_expiry = now + timedelta_from_string(self.provider.access_token_validity)
|
||||
auth_event = get_login_event(self.request)
|
||||
access_token = AccessToken(
|
||||
provider=self.provider,
|
||||
user=self.params.device_code.user,
|
||||
expires=access_token_expiry,
|
||||
scope=self.params.device_code.scope,
|
||||
auth_time=auth_event.created if auth_event else now,
|
||||
)
|
||||
access_token.id_token = IDToken.new(
|
||||
self.provider,
|
||||
@ -618,6 +626,7 @@ class TokenView(View):
|
||||
scope=self.params.device_code.scope,
|
||||
expires=refresh_token_expiry,
|
||||
provider=self.provider,
|
||||
auth_time=auth_event.created if auth_event else now,
|
||||
)
|
||||
id_token = IDToken.new(
|
||||
self.provider,
|
||||
|
@ -73,9 +73,9 @@ class AssertionProcessor:
|
||||
# https://commons.lbl.gov/display/IDMgmt/Attribute+Definitions
|
||||
attribute_statement = Element(f"{{{NS_SAML_ASSERTION}}}AttributeStatement")
|
||||
user = self.http_request.user
|
||||
for mapping in self.provider.property_mappings.all().select_subclasses():
|
||||
if not isinstance(mapping, SAMLPropertyMapping):
|
||||
continue
|
||||
for mapping in SAMLPropertyMapping.objects.filter(provider=self.provider).order_by(
|
||||
"saml_name"
|
||||
):
|
||||
try:
|
||||
mapping: SAMLPropertyMapping
|
||||
value = mapping.evaluate(
|
||||
|
@ -1,6 +0,0 @@
|
||||
"""saml provider settings"""
|
||||
|
||||
AUTHENTIK_PROVIDERS_SAML_PROCESSORS = [
|
||||
"authentik.providers.saml.processors.generic",
|
||||
"authentik.providers.saml.processors.salesforce",
|
||||
]
|
@ -59,7 +59,7 @@ class TestServiceProviderMetadataParser(TestCase):
|
||||
request = self.factory.get("/")
|
||||
metadata = lxml_from_string(MetadataProcessor(provider, request).build_entity_descriptor())
|
||||
|
||||
schema = etree.XMLSchema(etree.parse("xml/saml-schema-metadata-2.0.xsd")) # nosec
|
||||
schema = etree.XMLSchema(etree.parse("schemas/saml-schema-metadata-2.0.xsd")) # nosec
|
||||
self.assertTrue(schema.validate(metadata))
|
||||
|
||||
def test_simple(self):
|
||||
|
@ -46,7 +46,7 @@ class TestSchema(TestCase):
|
||||
|
||||
metadata = lxml_from_string(request)
|
||||
|
||||
schema = etree.XMLSchema(etree.parse("xml/saml-schema-protocol-2.0.xsd")) # nosec
|
||||
schema = etree.XMLSchema(etree.parse("schemas/saml-schema-protocol-2.0.xsd")) # nosec
|
||||
self.assertTrue(schema.validate(metadata))
|
||||
|
||||
def test_response_schema(self):
|
||||
@ -67,5 +67,5 @@ class TestSchema(TestCase):
|
||||
|
||||
metadata = lxml_from_string(response)
|
||||
|
||||
schema = etree.XMLSchema(etree.parse("xml/saml-schema-protocol-2.0.xsd"))
|
||||
schema = etree.XMLSchema(etree.parse("schemas/saml-schema-protocol-2.0.xsd")) # nosec
|
||||
self.assertTrue(schema.validate(metadata))
|
||||
|
@ -1,7 +1,7 @@
|
||||
"""authentik SAML IDP Views"""
|
||||
from typing import Optional
|
||||
|
||||
from django.http import HttpRequest, HttpResponse
|
||||
from django.http import Http404, HttpRequest, HttpResponse
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.utils.translation import gettext as _
|
||||
@ -11,6 +11,7 @@ from structlog.stdlib import get_logger
|
||||
|
||||
from authentik.core.models import Application
|
||||
from authentik.events.models import Event, EventAction
|
||||
from authentik.flows.exceptions import FlowNonApplicableException
|
||||
from authentik.flows.models import in_memory_stage
|
||||
from authentik.flows.planner import PLAN_CONTEXT_APPLICATION, PLAN_CONTEXT_SSO, FlowPlanner
|
||||
from authentik.flows.views.executor import SESSION_KEY_PLAN, SESSION_KEY_POST
|
||||
@ -60,16 +61,19 @@ class SAMLSSOView(PolicyAccessView):
|
||||
# Regardless, we start the planner and return to it
|
||||
planner = FlowPlanner(self.provider.authorization_flow)
|
||||
planner.allow_empty_flows = True
|
||||
plan = planner.plan(
|
||||
request,
|
||||
{
|
||||
PLAN_CONTEXT_SSO: True,
|
||||
PLAN_CONTEXT_APPLICATION: self.application,
|
||||
PLAN_CONTEXT_CONSENT_HEADER: _("You're about to sign into %(application)s.")
|
||||
% {"application": self.application.name},
|
||||
PLAN_CONTEXT_CONSENT_PERMISSIONS: [],
|
||||
},
|
||||
)
|
||||
try:
|
||||
plan = planner.plan(
|
||||
request,
|
||||
{
|
||||
PLAN_CONTEXT_SSO: True,
|
||||
PLAN_CONTEXT_APPLICATION: self.application,
|
||||
PLAN_CONTEXT_CONSENT_HEADER: _("You're about to sign into %(application)s.")
|
||||
% {"application": self.application.name},
|
||||
PLAN_CONTEXT_CONSENT_PERMISSIONS: [],
|
||||
},
|
||||
)
|
||||
except FlowNonApplicableException:
|
||||
raise Http404
|
||||
plan.append_stage(in_memory_stage(SAMLFlowFinalView))
|
||||
request.session[SESSION_KEY_PLAN] = plan
|
||||
return redirect_with_qs(
|
||||
|
0
authentik/providers/scim/__init__.py
Normal file
0
authentik/providers/scim/__init__.py
Normal file
0
authentik/providers/scim/api/__init__.py
Normal file
0
authentik/providers/scim/api/__init__.py
Normal file
38
authentik/providers/scim/api/property_mapping.py
Normal file
38
authentik/providers/scim/api/property_mapping.py
Normal file
@ -0,0 +1,38 @@
|
||||
"""scim Property mappings API Views"""
|
||||
from django_filters.filters import AllValuesMultipleFilter
|
||||
from django_filters.filterset import FilterSet
|
||||
from drf_spectacular.types import OpenApiTypes
|
||||
from drf_spectacular.utils import extend_schema_field
|
||||
from rest_framework.viewsets import ModelViewSet
|
||||
|
||||
from authentik.core.api.propertymappings import PropertyMappingSerializer
|
||||
from authentik.core.api.used_by import UsedByMixin
|
||||
from authentik.providers.scim.models import SCIMMapping
|
||||
|
||||
|
||||
class SCIMMappingSerializer(PropertyMappingSerializer):
|
||||
"""SCIMMapping Serializer"""
|
||||
|
||||
class Meta:
|
||||
model = SCIMMapping
|
||||
fields = PropertyMappingSerializer.Meta.fields
|
||||
|
||||
|
||||
class SCIMMappingFilter(FilterSet):
|
||||
"""Filter for SCIMMapping"""
|
||||
|
||||
managed = extend_schema_field(OpenApiTypes.STR)(AllValuesMultipleFilter(field_name="managed"))
|
||||
|
||||
class Meta:
|
||||
model = SCIMMapping
|
||||
fields = "__all__"
|
||||
|
||||
|
||||
class SCIMMappingViewSet(UsedByMixin, ModelViewSet):
|
||||
"""SCIMMapping Viewset"""
|
||||
|
||||
queryset = SCIMMapping.objects.all()
|
||||
serializer_class = SCIMMappingSerializer
|
||||
filterset_class = SCIMMappingFilter
|
||||
search_fields = ["name"]
|
||||
ordering = ["name"]
|
62
authentik/providers/scim/api/providers.py
Normal file
62
authentik/providers/scim/api/providers.py
Normal file
@ -0,0 +1,62 @@
|
||||
"""SCIM Provider API Views"""
|
||||
from django.utils.text import slugify
|
||||
from drf_spectacular.utils import OpenApiResponse, extend_schema
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.request import Request
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.viewsets import ModelViewSet
|
||||
|
||||
from authentik.admin.api.tasks import TaskSerializer
|
||||
from authentik.core.api.providers import ProviderSerializer
|
||||
from authentik.core.api.used_by import UsedByMixin
|
||||
from authentik.events.monitored_tasks import TaskInfo
|
||||
from authentik.providers.scim.models import SCIMProvider
|
||||
|
||||
|
||||
class SCIMProviderSerializer(ProviderSerializer):
|
||||
"""SCIMProvider Serializer"""
|
||||
|
||||
class Meta:
|
||||
model = SCIMProvider
|
||||
fields = [
|
||||
"pk",
|
||||
"name",
|
||||
"property_mappings",
|
||||
"property_mappings_group",
|
||||
"component",
|
||||
"assigned_application_slug",
|
||||
"assigned_application_name",
|
||||
"verbose_name",
|
||||
"verbose_name_plural",
|
||||
"meta_model_name",
|
||||
"url",
|
||||
"token",
|
||||
"exclude_users_service_account",
|
||||
"filter_group",
|
||||
]
|
||||
extra_kwargs = {}
|
||||
|
||||
|
||||
class SCIMProviderViewSet(UsedByMixin, ModelViewSet):
|
||||
"""SCIMProvider Viewset"""
|
||||
|
||||
queryset = SCIMProvider.objects.all()
|
||||
serializer_class = SCIMProviderSerializer
|
||||
filterset_fields = ["name", "exclude_users_service_account", "url", "filter_group"]
|
||||
search_fields = ["name", "url"]
|
||||
ordering = ["name", "url"]
|
||||
|
||||
@extend_schema(
|
||||
responses={
|
||||
200: TaskSerializer(),
|
||||
404: OpenApiResponse(description="Task not found"),
|
||||
}
|
||||
)
|
||||
@action(methods=["GET"], detail=True, pagination_class=None, filter_backends=[])
|
||||
def sync_status(self, request: Request, pk: int) -> Response:
|
||||
"""Get provider's sync status"""
|
||||
provider = self.get_object()
|
||||
task = TaskInfo.by_name(f"scim_sync:{slugify(provider.name)}")
|
||||
if not task:
|
||||
return Response(status=404)
|
||||
return Response(TaskSerializer(task).data)
|
15
authentik/providers/scim/apps.py
Normal file
15
authentik/providers/scim/apps.py
Normal file
@ -0,0 +1,15 @@
|
||||
"""authentik SCIM Provider app config"""
|
||||
from authentik.blueprints.apps import ManagedAppConfig
|
||||
|
||||
|
||||
class AuthentikProviderSCIMConfig(ManagedAppConfig):
|
||||
"""authentik SCIM Provider app config"""
|
||||
|
||||
name = "authentik.providers.scim"
|
||||
label = "authentik_providers_scim"
|
||||
verbose_name = "authentik Providers.SCIM"
|
||||
default = True
|
||||
|
||||
def reconcile_load_signals(self):
|
||||
"""Load signals"""
|
||||
self.import_module("authentik.providers.scim.signals")
|
2
authentik/providers/scim/clients/__init__.py
Normal file
2
authentik/providers/scim/clients/__init__.py
Normal file
@ -0,0 +1,2 @@
|
||||
"""SCIM constants"""
|
||||
PAGE_SIZE = 100
|
105
authentik/providers/scim/clients/base.py
Normal file
105
authentik/providers/scim/clients/base.py
Normal file
@ -0,0 +1,105 @@
|
||||
"""SCIM Client"""
|
||||
from typing import Generic, TypeVar
|
||||
|
||||
from pydantic import ValidationError
|
||||
from pydanticscim.service_provider import (
|
||||
Bulk,
|
||||
ChangePassword,
|
||||
Filter,
|
||||
Patch,
|
||||
ServiceProviderConfiguration,
|
||||
Sort,
|
||||
)
|
||||
from requests import RequestException, Session
|
||||
from structlog.stdlib import get_logger
|
||||
|
||||
from authentik.lib.utils.http import get_http_session
|
||||
from authentik.providers.scim.clients.exceptions import SCIMRequestException
|
||||
from authentik.providers.scim.models import SCIMProvider
|
||||
|
||||
T = TypeVar("T")
|
||||
# pylint: disable=invalid-name
|
||||
SchemaType = TypeVar("SchemaType")
|
||||
|
||||
|
||||
def default_service_provider_config() -> ServiceProviderConfiguration:
|
||||
"""Fallback service provider configuration"""
|
||||
return ServiceProviderConfiguration(
|
||||
patch=Patch(supported=False),
|
||||
bulk=Bulk(supported=False),
|
||||
filter=Filter(supported=False),
|
||||
changePassword=ChangePassword(supported=False),
|
||||
sort=Sort(supported=False),
|
||||
authenticationSchemes=[],
|
||||
)
|
||||
|
||||
|
||||
class SCIMClient(Generic[T, SchemaType]):
|
||||
"""SCIM Client"""
|
||||
|
||||
base_url: str
|
||||
token: str
|
||||
provider: SCIMProvider
|
||||
|
||||
_session: Session
|
||||
_config: ServiceProviderConfiguration
|
||||
|
||||
def __init__(self, provider: SCIMProvider):
|
||||
self._session = get_http_session()
|
||||
self.provider = provider
|
||||
# Remove trailing slashes as we assume the URL doesn't have any
|
||||
base_url = provider.url
|
||||
if base_url.endswith("/"):
|
||||
base_url = base_url[:-1]
|
||||
self.base_url = base_url
|
||||
self.token = provider.token
|
||||
self.logger = get_logger().bind(provider=provider.name)
|
||||
self._config = self.get_service_provider_config()
|
||||
|
||||
def _request(self, method: str, path: str, **kwargs) -> dict:
|
||||
"""Wrapper to send a request to the full URL"""
|
||||
try:
|
||||
response = self._session.request(
|
||||
method,
|
||||
f"{self.base_url}{path}",
|
||||
**kwargs,
|
||||
headers={
|
||||
"Authorization": f"Bearer {self.token}",
|
||||
"Accept": "application/scim+json",
|
||||
"Content-Type": "application/scim+json",
|
||||
},
|
||||
)
|
||||
except RequestException as exc:
|
||||
raise SCIMRequestException(None) from exc
|
||||
self.logger.debug("scim request", path=path, method=method, **kwargs)
|
||||
if response.status_code >= 400:
|
||||
self.logger.warning(
|
||||
"Failed to send SCIM request", path=path, method=method, response=response.text
|
||||
)
|
||||
raise SCIMRequestException(response)
|
||||
if response.status_code == 204:
|
||||
return {}
|
||||
return response.json()
|
||||
|
||||
def get_service_provider_config(self):
|
||||
"""Get Service provider config"""
|
||||
default_config = default_service_provider_config()
|
||||
try:
|
||||
return ServiceProviderConfiguration.parse_obj(
|
||||
self._request("GET", "/ServiceProviderConfig")
|
||||
)
|
||||
except (ValidationError, SCIMRequestException) as exc:
|
||||
self.logger.warning("failed to get ServiceProviderConfig", exc=exc)
|
||||
return default_config
|
||||
|
||||
def write(self, obj: T):
|
||||
"""Write object to SCIM"""
|
||||
raise NotImplementedError()
|
||||
|
||||
def delete(self, obj: T):
|
||||
"""Delete object from SCIM"""
|
||||
raise NotImplementedError()
|
||||
|
||||
def to_scim(self, obj: T) -> SchemaType:
|
||||
"""Convert object to scim"""
|
||||
raise NotImplementedError()
|
43
authentik/providers/scim/clients/exceptions.py
Normal file
43
authentik/providers/scim/clients/exceptions.py
Normal file
@ -0,0 +1,43 @@
|
||||
"""SCIM Client exceptions"""
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import ValidationError
|
||||
from pydanticscim.responses import SCIMError
|
||||
from requests import Response
|
||||
|
||||
from authentik.lib.sentry import SentryIgnoredException
|
||||
|
||||
|
||||
class StopSync(SentryIgnoredException):
|
||||
"""Exception raised when a configuration error should stop the sync process"""
|
||||
|
||||
def __init__(self, exc: Exception, obj: object, mapping: Optional[object] = None) -> None:
|
||||
self.exc = exc
|
||||
self.obj = obj
|
||||
self.mapping = mapping
|
||||
|
||||
def __str__(self) -> str:
|
||||
msg = f"Error {str(self.exc)}, caused by {self.obj}"
|
||||
|
||||
if self.mapping:
|
||||
msg += f" (mapping {self.mapping})"
|
||||
return msg
|
||||
|
||||
|
||||
class SCIMRequestException(SentryIgnoredException):
|
||||
"""Exception raised when an SCIM request fails"""
|
||||
|
||||
_response: Optional[Response]
|
||||
|
||||
def __init__(self, response: Optional[Response] = None) -> None:
|
||||
self._response = response
|
||||
|
||||
def __str__(self) -> str:
|
||||
if not self._response:
|
||||
return super().__str__()
|
||||
try:
|
||||
error = SCIMError.parse_raw(self._response.text)
|
||||
return error.detail
|
||||
except ValidationError:
|
||||
pass
|
||||
return super().__str__()
|
167
authentik/providers/scim/clients/group.py
Normal file
167
authentik/providers/scim/clients/group.py
Normal file
@ -0,0 +1,167 @@
|
||||
"""Group client"""
|
||||
from deepmerge import always_merger
|
||||
from pydantic import ValidationError
|
||||
from pydanticscim.group import GroupMember
|
||||
from pydanticscim.responses import PatchOp, PatchOperation, PatchRequest
|
||||
|
||||
from authentik.core.exceptions import PropertyMappingExpressionException
|
||||
from authentik.core.models import Group
|
||||
from authentik.events.models import Event, EventAction
|
||||
from authentik.lib.utils.errors import exception_to_string
|
||||
from authentik.policies.utils import delete_none_keys
|
||||
from authentik.providers.scim.clients.base import SCIMClient
|
||||
from authentik.providers.scim.clients.exceptions import StopSync
|
||||
from authentik.providers.scim.clients.schema import Group as SCIMGroupSchema
|
||||
from authentik.providers.scim.models import SCIMGroup, SCIMMapping, SCIMUser
|
||||
|
||||
|
||||
class SCIMGroupClient(SCIMClient[Group, SCIMGroupSchema]):
|
||||
"""SCIM client for groups"""
|
||||
|
||||
def write(self, obj: Group):
|
||||
"""Write a group"""
|
||||
scim_group = SCIMGroup.objects.filter(provider=self.provider, group=obj).first()
|
||||
if not scim_group:
|
||||
return self._create(obj)
|
||||
scim_group = self.to_scim(obj)
|
||||
scim_group.id = scim_group.id
|
||||
return self._request(
|
||||
"PUT",
|
||||
f"/Groups/{scim_group.id}",
|
||||
data=scim_group.json(
|
||||
exclude_unset=True,
|
||||
),
|
||||
)
|
||||
|
||||
def delete(self, obj: Group):
|
||||
"""Delete group"""
|
||||
scim_group = SCIMGroup.objects.filter(provider=self.provider, group=obj).first()
|
||||
if not scim_group:
|
||||
self.logger.debug("Group does not exist in SCIM, skipping")
|
||||
return None
|
||||
response = self._request("DELETE", f"/Groups/{scim_group.id}")
|
||||
scim_group.delete()
|
||||
return response
|
||||
|
||||
def to_scim(self, obj: Group) -> SCIMGroupSchema:
|
||||
"""Convert authentik user into SCIM"""
|
||||
raw_scim_group = {}
|
||||
for mapping in (
|
||||
self.provider.property_mappings_group.all().order_by("name").select_subclasses()
|
||||
):
|
||||
if not isinstance(mapping, SCIMMapping):
|
||||
continue
|
||||
try:
|
||||
mapping: SCIMMapping
|
||||
value = mapping.evaluate(
|
||||
user=None,
|
||||
request=None,
|
||||
group=obj,
|
||||
provider=self.provider,
|
||||
)
|
||||
if value is None:
|
||||
continue
|
||||
always_merger.merge(raw_scim_group, value)
|
||||
except (PropertyMappingExpressionException, ValueError) as exc:
|
||||
# Value error can be raised when assigning invalid data to an attribute
|
||||
Event.new(
|
||||
EventAction.CONFIGURATION_ERROR,
|
||||
message=f"Failed to evaluate property-mapping {exception_to_string(exc)}",
|
||||
mapping=mapping,
|
||||
).save()
|
||||
raise StopSync(exc, obj, mapping) from exc
|
||||
if not raw_scim_group:
|
||||
raise StopSync(ValueError("No group mappings configured"), obj)
|
||||
try:
|
||||
scim_group = SCIMGroupSchema.parse_obj(delete_none_keys(raw_scim_group))
|
||||
except ValidationError as exc:
|
||||
raise StopSync(exc, obj) from exc
|
||||
if not scim_group.externalId:
|
||||
scim_group.externalId = str(obj.pk)
|
||||
|
||||
users = list(obj.users.order_by("id").values_list("id", flat=True))
|
||||
connections = SCIMUser.objects.filter(provider=self.provider, user__pk__in=users)
|
||||
for user in connections:
|
||||
scim_group.members.append(
|
||||
GroupMember(
|
||||
value=user.id,
|
||||
)
|
||||
)
|
||||
return scim_group
|
||||
|
||||
def _create(self, group: Group):
|
||||
"""Create group from scratch and create a connection object"""
|
||||
scim_group = self.to_scim(group)
|
||||
response = self._request(
|
||||
"POST",
|
||||
"/Groups",
|
||||
data=scim_group.json(
|
||||
exclude_unset=True,
|
||||
),
|
||||
)
|
||||
SCIMGroup.objects.create(provider=self.provider, group=group, id=response["id"])
|
||||
|
||||
def _patch(
|
||||
self,
|
||||
group_id: str,
|
||||
*ops: PatchOperation,
|
||||
):
|
||||
req = PatchRequest(Operations=ops)
|
||||
self._request("PATCH", f"/Groups/{group_id}", data=req.json(exclude_unset=True))
|
||||
|
||||
def update_group(self, group: Group, action: PatchOp, users_set: set[int]):
|
||||
"""Update a group, either using PUT to replace it or PATCH if supported"""
|
||||
if self._config.patch.supported:
|
||||
if action == PatchOp.add:
|
||||
return self._patch_add_users(group, users_set)
|
||||
if action == PatchOp.remove:
|
||||
return self._patch_remove_users(group, users_set)
|
||||
return self.write(group)
|
||||
|
||||
def _patch_add_users(self, group: Group, users_set: set[int]):
|
||||
"""Add users in users_set to group"""
|
||||
if len(users_set) < 1:
|
||||
return
|
||||
scim_group = SCIMGroup.objects.filter(provider=self.provider, group=group).first()
|
||||
if not scim_group:
|
||||
self.logger.warning(
|
||||
"could not sync group membership, group does not exist", group=group
|
||||
)
|
||||
return
|
||||
user_ids = list(
|
||||
SCIMUser.objects.filter(user__pk__in=users_set, provider=self.provider).values_list(
|
||||
"id", flat=True
|
||||
)
|
||||
)
|
||||
self._patch(
|
||||
scim_group.id,
|
||||
PatchOperation(
|
||||
op=PatchOp.add,
|
||||
path="members",
|
||||
value=[{"value": x} for x in user_ids],
|
||||
),
|
||||
)
|
||||
|
||||
def _patch_remove_users(self, group: Group, users_set: set[int]):
|
||||
"""Remove users in users_set from group"""
|
||||
if len(users_set) < 1:
|
||||
return
|
||||
scim_group = SCIMGroup.objects.filter(provider=self.provider, group=group).first()
|
||||
if not scim_group:
|
||||
self.logger.warning(
|
||||
"could not sync group membership, group does not exist", group=group
|
||||
)
|
||||
return
|
||||
user_ids = list(
|
||||
SCIMUser.objects.filter(user__pk__in=users_set, provider=self.provider).values_list(
|
||||
"id", flat=True
|
||||
)
|
||||
)
|
||||
self._patch(
|
||||
scim_group.id,
|
||||
PatchOperation(
|
||||
op=PatchOp.remove,
|
||||
path="members",
|
||||
value=[{"value": x} for x in user_ids],
|
||||
),
|
||||
)
|
17
authentik/providers/scim/clients/schema.py
Normal file
17
authentik/providers/scim/clients/schema.py
Normal file
@ -0,0 +1,17 @@
|
||||
"""Custom SCIM schemas"""
|
||||
from typing import Optional
|
||||
|
||||
from pydanticscim.group import Group as SCIMGroupSchema
|
||||
from pydanticscim.user import User as SCIMUserSchema
|
||||
|
||||
|
||||
class User(SCIMUserSchema):
|
||||
"""Modified User schema with added externalId field"""
|
||||
|
||||
externalId: Optional[str] = None
|
||||
|
||||
|
||||
class Group(SCIMGroupSchema):
|
||||
"""Modified Group schema with added externalId field"""
|
||||
|
||||
externalId: Optional[str] = None
|
92
authentik/providers/scim/clients/user.py
Normal file
92
authentik/providers/scim/clients/user.py
Normal file
@ -0,0 +1,92 @@
|
||||
"""User client"""
|
||||
from deepmerge import always_merger
|
||||
from pydantic import ValidationError
|
||||
|
||||
from authentik.core.exceptions import PropertyMappingExpressionException
|
||||
from authentik.core.models import User
|
||||
from authentik.events.models import Event, EventAction
|
||||
from authentik.lib.utils.errors import exception_to_string
|
||||
from authentik.policies.utils import delete_none_keys
|
||||
from authentik.providers.scim.clients.base import SCIMClient
|
||||
from authentik.providers.scim.clients.exceptions import StopSync
|
||||
from authentik.providers.scim.clients.schema import User as SCIMUserSchema
|
||||
from authentik.providers.scim.models import SCIMMapping, SCIMUser
|
||||
|
||||
|
||||
class SCIMUserClient(SCIMClient[User, SCIMUserSchema]):
|
||||
"""SCIM client for users"""
|
||||
|
||||
def write(self, obj: User):
|
||||
"""Write a user"""
|
||||
scim_user = SCIMUser.objects.filter(provider=self.provider, user=obj).first()
|
||||
if not scim_user:
|
||||
return self._create(obj)
|
||||
return self._update(obj, scim_user)
|
||||
|
||||
def delete(self, obj: User):
|
||||
"""Delete user"""
|
||||
scim_user = SCIMUser.objects.filter(provider=self.provider, user=obj).first()
|
||||
if not scim_user:
|
||||
self.logger.debug("User does not exist in SCIM, skipping")
|
||||
return None
|
||||
response = self._request("DELETE", f"/Users/{scim_user.id}")
|
||||
scim_user.delete()
|
||||
return response
|
||||
|
||||
def to_scim(self, obj: User) -> SCIMUserSchema:
|
||||
"""Convert authentik user into SCIM"""
|
||||
raw_scim_user = {}
|
||||
for mapping in self.provider.property_mappings.all().order_by("name").select_subclasses():
|
||||
if not isinstance(mapping, SCIMMapping):
|
||||
continue
|
||||
try:
|
||||
mapping: SCIMMapping
|
||||
value = mapping.evaluate(
|
||||
user=obj,
|
||||
request=None,
|
||||
provider=self.provider,
|
||||
)
|
||||
if value is None:
|
||||
continue
|
||||
always_merger.merge(raw_scim_user, value)
|
||||
except (PropertyMappingExpressionException, ValueError) as exc:
|
||||
# Value error can be raised when assigning invalid data to an attribute
|
||||
Event.new(
|
||||
EventAction.CONFIGURATION_ERROR,
|
||||
message=f"Failed to evaluate property-mapping {exception_to_string(exc)}",
|
||||
mapping=mapping,
|
||||
).save()
|
||||
raise StopSync(exc, obj, mapping) from exc
|
||||
if not raw_scim_user:
|
||||
raise StopSync(ValueError("No user mappings configured"), obj)
|
||||
try:
|
||||
scim_user = SCIMUserSchema.parse_obj(delete_none_keys(raw_scim_user))
|
||||
except ValidationError as exc:
|
||||
raise StopSync(exc, obj) from exc
|
||||
if not scim_user.externalId:
|
||||
scim_user.externalId = str(obj.uid)
|
||||
return scim_user
|
||||
|
||||
def _create(self, user: User):
|
||||
"""Create user from scratch and create a connection object"""
|
||||
scim_user = self.to_scim(user)
|
||||
response = self._request(
|
||||
"POST",
|
||||
"/Users",
|
||||
data=scim_user.json(
|
||||
exclude_unset=True,
|
||||
),
|
||||
)
|
||||
SCIMUser.objects.create(provider=self.provider, user=user, id=response["id"])
|
||||
|
||||
def _update(self, user: User, connection: SCIMUser):
|
||||
"""Update existing user"""
|
||||
scim_user = self.to_scim(user)
|
||||
scim_user.id = connection.id
|
||||
self._request(
|
||||
"PUT",
|
||||
f"/Users/{connection.id}",
|
||||
data=scim_user.json(
|
||||
exclude_unset=True,
|
||||
),
|
||||
)
|
62
authentik/providers/scim/migrations/0001_initial.py
Normal file
62
authentik/providers/scim/migrations/0001_initial.py
Normal file
@ -0,0 +1,62 @@
|
||||
# Generated by Django 4.1.7 on 2023-03-02 13:53
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
("authentik_core", "0024_source_icon"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="SCIMMapping",
|
||||
fields=[
|
||||
(
|
||||
"propertymapping_ptr",
|
||||
models.OneToOneField(
|
||||
auto_created=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
parent_link=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
to="authentik_core.propertymapping",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "SCIM Mapping",
|
||||
"verbose_name_plural": "SCIM Mappings",
|
||||
},
|
||||
bases=("authentik_core.propertymapping",),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="SCIMProvider",
|
||||
fields=[
|
||||
(
|
||||
"provider_ptr",
|
||||
models.OneToOneField(
|
||||
auto_created=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
parent_link=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
to="authentik_core.provider",
|
||||
),
|
||||
),
|
||||
(
|
||||
"url",
|
||||
models.TextField(help_text="Base URL to SCIM requests, usually ends in /v2"),
|
||||
),
|
||||
("token", models.TextField(help_text="Authentication token")),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "SCIM Provider",
|
||||
"verbose_name_plural": "SCIM Providers",
|
||||
},
|
||||
bases=("authentik_core.provider",),
|
||||
),
|
||||
]
|
@ -0,0 +1,137 @@
|
||||
# Generated by Django 4.1.7 on 2023-03-07 13:07
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
replaces = [
|
||||
("authentik_providers_scim", "0001_initial"),
|
||||
("authentik_providers_scim", "0002_scimuser"),
|
||||
("authentik_providers_scim", "0003_scimgroup"),
|
||||
("authentik_providers_scim", "0004_scimprovider_property_mappings_group"),
|
||||
("authentik_providers_scim", "0005_scimprovider_exclude_users_service_account_and_more"),
|
||||
("authentik_providers_scim", "0006_rename_parent_group_scimprovider_filter_group"),
|
||||
]
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
("authentik_core", "0024_source_icon"),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
("authentik_core", "0025_alter_provider_authorization_flow"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="SCIMMapping",
|
||||
fields=[
|
||||
(
|
||||
"propertymapping_ptr",
|
||||
models.OneToOneField(
|
||||
auto_created=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
parent_link=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
to="authentik_core.propertymapping",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "SCIM Mapping",
|
||||
"verbose_name_plural": "SCIM Mappings",
|
||||
},
|
||||
bases=("authentik_core.propertymapping",),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="SCIMProvider",
|
||||
fields=[
|
||||
(
|
||||
"provider_ptr",
|
||||
models.OneToOneField(
|
||||
auto_created=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
parent_link=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
to="authentik_core.provider",
|
||||
),
|
||||
),
|
||||
(
|
||||
"url",
|
||||
models.TextField(help_text="Base URL to SCIM requests, usually ends in /v2"),
|
||||
),
|
||||
("token", models.TextField(help_text="Authentication token")),
|
||||
(
|
||||
"property_mappings_group",
|
||||
models.ManyToManyField(
|
||||
blank=True,
|
||||
default=None,
|
||||
help_text="Property mappings used for group creation/updating.",
|
||||
to="authentik_core.propertymapping",
|
||||
),
|
||||
),
|
||||
("exclude_users_service_account", models.BooleanField(default=False)),
|
||||
(
|
||||
"filter_group",
|
||||
models.ForeignKey(
|
||||
default=None,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_DEFAULT,
|
||||
to="authentik_core.group",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "SCIM Provider",
|
||||
"verbose_name_plural": "SCIM Providers",
|
||||
},
|
||||
bases=("authentik_core.provider",),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="SCIMUser",
|
||||
fields=[
|
||||
("id", models.TextField(primary_key=True, serialize=False)),
|
||||
(
|
||||
"provider",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to="authentik_providers_scim.scimprovider",
|
||||
),
|
||||
),
|
||||
(
|
||||
"user",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"unique_together": {("id", "user", "provider")},
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="SCIMGroup",
|
||||
fields=[
|
||||
("id", models.TextField(primary_key=True, serialize=False)),
|
||||
(
|
||||
"group",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE, to="authentik_core.group"
|
||||
),
|
||||
),
|
||||
(
|
||||
"provider",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to="authentik_providers_scim.scimprovider",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"unique_together": {("id", "group", "provider")},
|
||||
},
|
||||
),
|
||||
]
|
37
authentik/providers/scim/migrations/0002_scimuser.py
Normal file
37
authentik/providers/scim/migrations/0002_scimuser.py
Normal file
@ -0,0 +1,37 @@
|
||||
# Generated by Django 4.1.7 on 2023-03-02 15:03
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
("authentik_providers_scim", "0001_initial"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="SCIMUser",
|
||||
fields=[
|
||||
("id", models.TextField(primary_key=True, serialize=False)),
|
||||
(
|
||||
"provider",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to="authentik_providers_scim.scimprovider",
|
||||
),
|
||||
),
|
||||
(
|
||||
"user",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"unique_together": {("id", "user", "provider")},
|
||||
},
|
||||
),
|
||||
]
|
36
authentik/providers/scim/migrations/0003_scimgroup.py
Normal file
36
authentik/providers/scim/migrations/0003_scimgroup.py
Normal file
@ -0,0 +1,36 @@
|
||||
# Generated by Django 4.1.7 on 2023-03-02 15:55
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("authentik_core", "0024_source_icon"),
|
||||
("authentik_providers_scim", "0002_scimuser"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="SCIMGroup",
|
||||
fields=[
|
||||
("id", models.TextField(primary_key=True, serialize=False)),
|
||||
(
|
||||
"group",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE, to="authentik_core.group"
|
||||
),
|
||||
),
|
||||
(
|
||||
"provider",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to="authentik_providers_scim.scimprovider",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"unique_together": {("id", "group", "provider")},
|
||||
},
|
||||
),
|
||||
]
|
@ -0,0 +1,23 @@
|
||||
# Generated by Django 4.1.7 on 2023-03-03 14:38
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("authentik_core", "0025_alter_provider_authorization_flow"),
|
||||
("authentik_providers_scim", "0003_scimgroup"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="scimprovider",
|
||||
name="property_mappings_group",
|
||||
field=models.ManyToManyField(
|
||||
blank=True,
|
||||
default=None,
|
||||
help_text="Property mappings used for group creation/updating.",
|
||||
to="authentik_core.propertymapping",
|
||||
),
|
||||
),
|
||||
]
|
@ -0,0 +1,29 @@
|
||||
# Generated by Django 4.1.7 on 2023-03-07 10:35
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("authentik_core", "0025_alter_provider_authorization_flow"),
|
||||
("authentik_providers_scim", "0004_scimprovider_property_mappings_group"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="scimprovider",
|
||||
name="exclude_users_service_account",
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="scimprovider",
|
||||
name="parent_group",
|
||||
field=models.ForeignKey(
|
||||
default=None,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_DEFAULT,
|
||||
to="authentik_core.group",
|
||||
),
|
||||
),
|
||||
]
|
@ -0,0 +1,17 @@
|
||||
# Generated by Django 4.1.7 on 2023-03-07 13:07
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("authentik_providers_scim", "0005_scimprovider_exclude_users_service_account_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RenameField(
|
||||
model_name="scimprovider",
|
||||
old_name="parent_group",
|
||||
new_name="filter_group",
|
||||
),
|
||||
]
|
0
authentik/providers/scim/migrations/__init__.py
Normal file
0
authentik/providers/scim/migrations/__init__.py
Normal file
113
authentik/providers/scim/models.py
Normal file
113
authentik/providers/scim/models.py
Normal file
@ -0,0 +1,113 @@
|
||||
"""SCIM Provider models"""
|
||||
from django.db import models
|
||||
from django.db.models import Q, QuerySet
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from guardian.shortcuts import get_anonymous_user
|
||||
from rest_framework.serializers import Serializer
|
||||
|
||||
from authentik.core.models import USER_ATTRIBUTE_SA, Group, PropertyMapping, Provider, User
|
||||
|
||||
|
||||
class SCIMProvider(Provider):
|
||||
"""SCIM 2.0 provider to create users and groups in external applications"""
|
||||
|
||||
exclude_users_service_account = models.BooleanField(default=False)
|
||||
|
||||
filter_group = models.ForeignKey(
|
||||
"authentik_core.group", on_delete=models.SET_DEFAULT, default=None, null=True
|
||||
)
|
||||
|
||||
url = models.TextField(help_text=_("Base URL to SCIM requests, usually ends in /v2"))
|
||||
token = models.TextField(help_text=_("Authentication token"))
|
||||
|
||||
property_mappings_group = models.ManyToManyField(
|
||||
PropertyMapping,
|
||||
default=None,
|
||||
blank=True,
|
||||
help_text=_("Property mappings used for group creation/updating."),
|
||||
)
|
||||
|
||||
def get_user_qs(self) -> QuerySet[User]:
|
||||
"""Get queryset of all users with consistent ordering
|
||||
according to the provider's settings"""
|
||||
base = User.objects.all().exclude(pk=get_anonymous_user().pk)
|
||||
if self.exclude_users_service_account:
|
||||
base = base.filter(
|
||||
Q(
|
||||
**{
|
||||
f"attributes__{USER_ATTRIBUTE_SA}__isnull": True,
|
||||
}
|
||||
)
|
||||
| Q(
|
||||
**{
|
||||
f"attributes__{USER_ATTRIBUTE_SA}": False,
|
||||
}
|
||||
)
|
||||
)
|
||||
if self.filter_group:
|
||||
base = base.filter(ak_groups__in=[self.filter_group])
|
||||
return base.order_by("pk")
|
||||
|
||||
def get_group_qs(self) -> QuerySet[Group]:
|
||||
"""Get queryset of all groups with consistent ordering"""
|
||||
return Group.objects.all().order_by("pk")
|
||||
|
||||
@property
|
||||
def component(self) -> str:
|
||||
return "ak-provider-scim-form"
|
||||
|
||||
@property
|
||||
def serializer(self) -> type[Serializer]:
|
||||
from authentik.providers.scim.api.providers import SCIMProviderSerializer
|
||||
|
||||
return SCIMProviderSerializer
|
||||
|
||||
def __str__(self):
|
||||
return f"SCIM Provider {self.name}"
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("SCIM Provider")
|
||||
verbose_name_plural = _("SCIM Providers")
|
||||
|
||||
|
||||
class SCIMMapping(PropertyMapping):
|
||||
"""Map authentik data to outgoing SCIM requests"""
|
||||
|
||||
@property
|
||||
def component(self) -> str:
|
||||
return "ak-property-mapping-scim-form"
|
||||
|
||||
@property
|
||||
def serializer(self) -> type[Serializer]:
|
||||
from authentik.providers.scim.api.property_mapping import SCIMMappingSerializer
|
||||
|
||||
return SCIMMappingSerializer
|
||||
|
||||
def __str__(self):
|
||||
return f"SCIM Mapping {self.name}"
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("SCIM Mapping")
|
||||
verbose_name_plural = _("SCIM Mappings")
|
||||
|
||||
|
||||
class SCIMUser(models.Model):
|
||||
"""Mapping of a user and provider to a SCIM user ID"""
|
||||
|
||||
id = models.TextField(primary_key=True)
|
||||
user = models.ForeignKey(User, on_delete=models.CASCADE)
|
||||
provider = models.ForeignKey(SCIMProvider, on_delete=models.CASCADE)
|
||||
|
||||
class Meta:
|
||||
unique_together = (("id", "user", "provider"),)
|
||||
|
||||
|
||||
class SCIMGroup(models.Model):
|
||||
"""Mapping of a group and provider to a SCIM user ID"""
|
||||
|
||||
id = models.TextField(primary_key=True)
|
||||
group = models.ForeignKey(Group, on_delete=models.CASCADE)
|
||||
provider = models.ForeignKey(SCIMProvider, on_delete=models.CASCADE)
|
||||
|
||||
class Meta:
|
||||
unique_together = (("id", "group", "provider"),)
|
12
authentik/providers/scim/settings.py
Normal file
12
authentik/providers/scim/settings.py
Normal file
@ -0,0 +1,12 @@
|
||||
"""SCIM task Settings"""
|
||||
from celery.schedules import crontab
|
||||
|
||||
from authentik.lib.utils.time import fqdn_rand
|
||||
|
||||
CELERY_BEAT_SCHEDULE = {
|
||||
"providers_scim_sync": {
|
||||
"task": "authentik.providers.scim.tasks.scim_sync_all",
|
||||
"schedule": crontab(minute=fqdn_rand("scim_sync_all"), hour="*"),
|
||||
"options": {"queue": "authentik_scheduled"},
|
||||
},
|
||||
}
|
41
authentik/providers/scim/signals.py
Normal file
41
authentik/providers/scim/signals.py
Normal file
@ -0,0 +1,41 @@
|
||||
"""SCIM provider signals"""
|
||||
from django.db.models import Model
|
||||
from django.db.models.signals import m2m_changed, post_save, pre_delete
|
||||
from django.dispatch import receiver
|
||||
from pydanticscim.responses import PatchOp
|
||||
from structlog.stdlib import get_logger
|
||||
|
||||
from authentik.core.models import Group, User
|
||||
from authentik.lib.utils.reflection import class_to_path
|
||||
from authentik.providers.scim.models import SCIMProvider
|
||||
from authentik.providers.scim.tasks import scim_signal_direct, scim_signal_m2m, scim_sync
|
||||
|
||||
LOGGER = get_logger()
|
||||
|
||||
|
||||
@receiver(post_save, sender=SCIMProvider)
|
||||
def post_save_provider(sender: type[Model], instance, created: bool, **_):
|
||||
"""Trigger sync when SCIM provider is saved"""
|
||||
scim_sync.delay(instance.pk)
|
||||
|
||||
|
||||
@receiver(post_save, sender=User)
|
||||
@receiver(post_save, sender=Group)
|
||||
def post_save_scim(sender: type[Model], instance: User | Group, created: bool, **_):
|
||||
"""Post save handler"""
|
||||
scim_signal_direct.delay(class_to_path(instance.__class__), instance.pk, PatchOp.add.value)
|
||||
|
||||
|
||||
@receiver(pre_delete, sender=User)
|
||||
@receiver(pre_delete, sender=Group)
|
||||
def pre_delete_scim(sender: type[Model], instance: User | Group, **_):
|
||||
"""Pre-delete handler"""
|
||||
scim_signal_direct.delay(class_to_path(instance.__class__), instance.pk, PatchOp.remove.value)
|
||||
|
||||
|
||||
@receiver(m2m_changed, sender=User.ak_groups.through)
|
||||
def m2m_changed_scim(sender: type[Model], instance, action: str, pk_set: set, **kwargs):
|
||||
"""Sync group membership"""
|
||||
if action not in ["post_add", "post_remove"]:
|
||||
return
|
||||
scim_signal_m2m.delay(str(instance.pk), action, list(pk_set))
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user