Compare commits
	
		
			72 Commits
		
	
	
		
			5165-passw
			...
			version/20
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| d6904b6aa1 | |||
| cd581efacd | |||
| 6c159d120b | |||
| 4ddd4e7f88 | |||
| 441912414f | |||
| 9e177ed5c0 | |||
| 881548176f | |||
| 56739d0dc4 | |||
| b23972e9c9 | |||
| 0a9595089e | |||
| 72c22b5fab | |||
| 84cdbb0a03 | |||
| 9fc659f121 | |||
| db6abf61b8 | |||
| 6426a1d177 | |||
| 9075270b01 | |||
| d17a39a431 | |||
| db1d091d2e | |||
| f98204e78e | |||
| 3f663cab0f | |||
| 3fe129e107 | |||
| f26d41aef9 | |||
| 5d8b5998ae | |||
| 7a5e136346 | |||
| bfbab6357a | |||
| 5997b93f15 | |||
| 6cdae09dc0 | |||
| ff0ef7a2b3 | |||
| 3986104a20 | |||
| 1aa60e7864 | |||
| 045578dd07 | |||
| f23d70dc75 | |||
| 496f3426d9 | |||
| 17acc9457d | |||
| 2996f20b74 | |||
| dd86a90225 | |||
| 3b1034b9a2 | |||
| ba87fd8714 | |||
| ccebe355aa | |||
| 49fe670932 | |||
| f1d173f94e | |||
| 19e0a282c6 | |||
| 234f06a362 | |||
| 0bbbc7def2 | |||
| 43fd3eecda | |||
| 631b120e4f | |||
| 9ea517d606 | |||
| 7b7a7e3073 | |||
| ca3cdc3fd2 | |||
| 6e12277903 | |||
| 2f42144b33 | |||
| eef02f2892 | |||
| b6157ecaf1 | |||
| 35cd126406 | |||
| f89a4fc276 | |||
| 4d7f380b2d | |||
| cb8379031a | |||
| 0c604ceba4 | |||
| 30e39c75ff | |||
| 6d7bebbcc3 | |||
| dc332ec7b0 | |||
| 31e94a2814 | |||
| eb08214f0e | |||
| a5ab8a618e | |||
| b8cbdcae22 | |||
| ae86184511 | |||
| b704388c2f | |||
| a35f9fdd7b | |||
| d95220be0e | |||
| ba1b86efa1 | |||
| cd93de1141 | |||
| cc148bd552 | 
| @ -1,5 +1,5 @@ | |||||||
| [bumpversion] | [bumpversion] | ||||||
| current_version = 2024.4.2 | current_version = 2024.6.2 | ||||||
| tag = True | tag = True | ||||||
| commit = True | commit = True | ||||||
| parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)(?:-(?P<rc_t>[a-zA-Z-]+)(?P<rc_n>[1-9]\\d*))? | parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)(?:-(?P<rc_t>[a-zA-Z-]+)(?P<rc_n>[1-9]\\d*))? | ||||||
| @ -17,6 +17,8 @@ optional_value = final | |||||||
|  |  | ||||||
| [bumpversion:file:pyproject.toml] | [bumpversion:file:pyproject.toml] | ||||||
|  |  | ||||||
|  | [bumpversion:file:package.json] | ||||||
|  |  | ||||||
| [bumpversion:file:docker-compose.yml] | [bumpversion:file:docker-compose.yml] | ||||||
|  |  | ||||||
| [bumpversion:file:schema.yml] | [bumpversion:file:schema.yml] | ||||||
|  | |||||||
							
								
								
									
										38
									
								
								.github/dependabot.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										38
									
								
								.github/dependabot.yml
									
									
									
									
										vendored
									
									
								
							| @ -21,7 +21,10 @@ updates: | |||||||
|     labels: |     labels: | ||||||
|       - dependencies |       - dependencies | ||||||
|   - package-ecosystem: npm |   - package-ecosystem: npm | ||||||
|     directory: "/web" |     directories: | ||||||
|  |       - "/web" | ||||||
|  |       - "/tests/wdio" | ||||||
|  |       - "/web/sfe" | ||||||
|     schedule: |     schedule: | ||||||
|       interval: daily |       interval: daily | ||||||
|       time: "04:00" |       time: "04:00" | ||||||
| @ -30,7 +33,6 @@ updates: | |||||||
|     open-pull-requests-limit: 10 |     open-pull-requests-limit: 10 | ||||||
|     commit-message: |     commit-message: | ||||||
|       prefix: "web:" |       prefix: "web:" | ||||||
|     # TODO: deduplicate these groups |  | ||||||
|     groups: |     groups: | ||||||
|       sentry: |       sentry: | ||||||
|         patterns: |         patterns: | ||||||
| @ -56,38 +58,6 @@ updates: | |||||||
|         patterns: |         patterns: | ||||||
|           - "@rollup/*" |           - "@rollup/*" | ||||||
|           - "rollup-*" |           - "rollup-*" | ||||||
|   - package-ecosystem: npm |  | ||||||
|     directory: "/tests/wdio" |  | ||||||
|     schedule: |  | ||||||
|       interval: daily |  | ||||||
|       time: "04:00" |  | ||||||
|     labels: |  | ||||||
|       - dependencies |  | ||||||
|     open-pull-requests-limit: 10 |  | ||||||
|     commit-message: |  | ||||||
|       prefix: "web:" |  | ||||||
|     # TODO: deduplicate these groups |  | ||||||
|     groups: |  | ||||||
|       sentry: |  | ||||||
|         patterns: |  | ||||||
|           - "@sentry/*" |  | ||||||
|           - "@spotlightjs/*" |  | ||||||
|       babel: |  | ||||||
|         patterns: |  | ||||||
|           - "@babel/*" |  | ||||||
|           - "babel-*" |  | ||||||
|       eslint: |  | ||||||
|         patterns: |  | ||||||
|           - "@typescript-eslint/*" |  | ||||||
|           - "eslint" |  | ||||||
|           - "eslint-*" |  | ||||||
|       storybook: |  | ||||||
|         patterns: |  | ||||||
|           - "@storybook/*" |  | ||||||
|           - "*storybook*" |  | ||||||
|       esbuild: |  | ||||||
|         patterns: |  | ||||||
|           - "@esbuild/*" |  | ||||||
|       wdio: |       wdio: | ||||||
|         patterns: |         patterns: | ||||||
|           - "@wdio/*" |           - "@wdio/*" | ||||||
|  | |||||||
							
								
								
									
										7
									
								
								.github/workflows/api-ts-publish.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										7
									
								
								.github/workflows/api-ts-publish.yml
									
									
									
									
										vendored
									
									
								
							| @ -31,7 +31,12 @@ jobs: | |||||||
|         env: |         env: | ||||||
|           NODE_AUTH_TOKEN: ${{ secrets.NPM_PUBLISH_TOKEN }} |           NODE_AUTH_TOKEN: ${{ secrets.NPM_PUBLISH_TOKEN }} | ||||||
|       - name: Upgrade /web |       - name: Upgrade /web | ||||||
|         working-directory: web/ |         working-directory: web | ||||||
|  |         run: | | ||||||
|  |           export VERSION=`node -e 'console.log(require("../gen-ts-api/package.json").version)'` | ||||||
|  |           npm i @goauthentik/api@$VERSION | ||||||
|  |       - name: Upgrade /web/sfe | ||||||
|  |         working-directory: web/sfe | ||||||
|         run: | |         run: | | ||||||
|           export VERSION=`node -e 'console.log(require("../gen-ts-api/package.json").version)'` |           export VERSION=`node -e 'console.log(require("../gen-ts-api/package.json").version)'` | ||||||
|           npm i @goauthentik/api@$VERSION |           npm i @goauthentik/api@$VERSION | ||||||
|  | |||||||
							
								
								
									
										10
									
								
								.github/workflows/ci-web.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										10
									
								
								.github/workflows/ci-web.yml
									
									
									
									
										vendored
									
									
								
							| @ -20,6 +20,16 @@ jobs: | |||||||
|         project: |         project: | ||||||
|           - web |           - web | ||||||
|           - tests/wdio |           - tests/wdio | ||||||
|  |         include: | ||||||
|  |           - command: tsc | ||||||
|  |             project: web | ||||||
|  |             extra_setup: | | ||||||
|  |               cd sfe/ && npm ci | ||||||
|  |         exclude: | ||||||
|  |           - command: lint:lockfile | ||||||
|  |             project: tests/wdio | ||||||
|  |           - command: tsc | ||||||
|  |             project: tests/wdio | ||||||
|     steps: |     steps: | ||||||
|       - uses: actions/checkout@v4 |       - uses: actions/checkout@v4 | ||||||
|       - uses: actions/setup-node@v4 |       - uses: actions/setup-node@v4 | ||||||
|  | |||||||
							
								
								
									
										12
									
								
								Dockerfile
									
									
									
									
									
								
							
							
						
						
									
										12
									
								
								Dockerfile
									
									
									
									
									
								
							| @ -22,20 +22,30 @@ RUN npm run build-bundled | |||||||
| # Stage 2: Build webui | # Stage 2: Build webui | ||||||
| FROM --platform=${BUILDPLATFORM} docker.io/node:22 as web-builder | FROM --platform=${BUILDPLATFORM} docker.io/node:22 as web-builder | ||||||
|  |  | ||||||
|  | ARG GIT_BUILD_HASH | ||||||
|  | ENV GIT_BUILD_HASH=$GIT_BUILD_HASH | ||||||
| ENV NODE_ENV=production | ENV NODE_ENV=production | ||||||
|  |  | ||||||
| WORKDIR /work/web | WORKDIR /work/web | ||||||
|  |  | ||||||
| RUN --mount=type=bind,target=/work/web/package.json,src=./web/package.json \ | RUN --mount=type=bind,target=/work/web/package.json,src=./web/package.json \ | ||||||
|     --mount=type=bind,target=/work/web/package-lock.json,src=./web/package-lock.json \ |     --mount=type=bind,target=/work/web/package-lock.json,src=./web/package-lock.json \ | ||||||
|  |     --mount=type=bind,target=/work/web/sfe/package.json,src=./web/sfe/package.json \ | ||||||
|  |     --mount=type=bind,target=/work/web/sfe/package-lock.json,src=./web/sfe/package-lock.json \ | ||||||
|  |     --mount=type=bind,target=/work/web/scripts,src=./web/scripts \ | ||||||
|     --mount=type=cache,id=npm-web,sharing=shared,target=/root/.npm \ |     --mount=type=cache,id=npm-web,sharing=shared,target=/root/.npm \ | ||||||
|  |     npm ci --include=dev && \ | ||||||
|  |     cd sfe && \ | ||||||
|     npm ci --include=dev |     npm ci --include=dev | ||||||
|  |  | ||||||
|  | COPY ./package.json /work | ||||||
| COPY ./web /work/web/ | COPY ./web /work/web/ | ||||||
| COPY ./website /work/website/ | COPY ./website /work/website/ | ||||||
| COPY ./gen-ts-api /work/web/node_modules/@goauthentik/api | COPY ./gen-ts-api /work/web/node_modules/@goauthentik/api | ||||||
|  |  | ||||||
| RUN npm run build | RUN npm run build && \ | ||||||
|  |     cd sfe && \ | ||||||
|  |     npm run build | ||||||
|  |  | ||||||
| # Stage 3: Build go proxy | # Stage 3: Build go proxy | ||||||
| FROM --platform=${BUILDPLATFORM} mcr.microsoft.com/oss/go/microsoft/golang:1.22-fips-bookworm AS go-builder | FROM --platform=${BUILDPLATFORM} mcr.microsoft.com/oss/go/microsoft/golang:1.22-fips-bookworm AS go-builder | ||||||
|  | |||||||
| @ -18,10 +18,10 @@ Even if the issue is not a CVE, we still greatly appreciate your help in hardeni | |||||||
|  |  | ||||||
| (.x being the latest patch release for each version) | (.x being the latest patch release for each version) | ||||||
|  |  | ||||||
| | Version   | Supported | | | Version  | Supported | | ||||||
| | --------- | --------- | | | -------- | --------- | | ||||||
| | 2023.10.x | ✅        | | | 2024.4.x | ✅        | | ||||||
| | 2024.2.x  | ✅        | | | 2024.6.x | ✅        | | ||||||
|  |  | ||||||
| ## Reporting a Vulnerability | ## Reporting a Vulnerability | ||||||
|  |  | ||||||
|  | |||||||
| @ -2,7 +2,7 @@ | |||||||
|  |  | ||||||
| from os import environ | from os import environ | ||||||
|  |  | ||||||
| __version__ = "2024.4.2" | __version__ = "2024.6.2" | ||||||
| ENV_GIT_HASH_KEY = "GIT_BUILD_HASH" | ENV_GIT_HASH_KEY = "GIT_BUILD_HASH" | ||||||
|  |  | ||||||
|  |  | ||||||
|  | |||||||
| @ -16,6 +16,7 @@ from rest_framework.views import APIView | |||||||
|  |  | ||||||
| from authentik import get_full_version | from authentik import get_full_version | ||||||
| from authentik.core.api.utils import PassiveSerializer | from authentik.core.api.utils import PassiveSerializer | ||||||
|  | from authentik.enterprise.license import LicenseKey | ||||||
| from authentik.lib.config import CONFIG | from authentik.lib.config import CONFIG | ||||||
| from authentik.lib.utils.reflection import get_env | from authentik.lib.utils.reflection import get_env | ||||||
| from authentik.outposts.apps import MANAGED_OUTPOST | from authentik.outposts.apps import MANAGED_OUTPOST | ||||||
| @ -32,7 +33,7 @@ class RuntimeDict(TypedDict): | |||||||
|     platform: str |     platform: str | ||||||
|     uname: str |     uname: str | ||||||
|     openssl_version: str |     openssl_version: str | ||||||
|     openssl_fips_mode: bool |     openssl_fips_enabled: bool | None | ||||||
|     authentik_version: str |     authentik_version: str | ||||||
|  |  | ||||||
|  |  | ||||||
| @ -71,7 +72,9 @@ class SystemInfoSerializer(PassiveSerializer): | |||||||
|             "architecture": platform.machine(), |             "architecture": platform.machine(), | ||||||
|             "authentik_version": get_full_version(), |             "authentik_version": get_full_version(), | ||||||
|             "environment": get_env(), |             "environment": get_env(), | ||||||
|             "openssl_fips_enabled": backend._fips_enabled, |             "openssl_fips_enabled": ( | ||||||
|  |                 backend._fips_enabled if LicenseKey.get_total().is_valid() else None | ||||||
|  |             ), | ||||||
|             "openssl_version": OPENSSL_VERSION, |             "openssl_version": OPENSSL_VERSION, | ||||||
|             "platform": platform.platform(), |             "platform": platform.platform(), | ||||||
|             "python_version": python_version, |             "python_version": python_version, | ||||||
|  | |||||||
| @ -1,13 +1,13 @@ | |||||||
| {% extends "base/skeleton.html" %} | {% extends "base/skeleton.html" %} | ||||||
|  |  | ||||||
| {% load static %} | {% load authentik_core %} | ||||||
|  |  | ||||||
| {% block title %} | {% block title %} | ||||||
| API Browser - {{ brand.branding_title }} | API Browser - {{ brand.branding_title }} | ||||||
| {% endblock %} | {% endblock %} | ||||||
|  |  | ||||||
| {% block head %} | {% block head %} | ||||||
| <script src="{% static 'dist/standalone/api-browser/index.js' %}?version={{ version }}" type="module"></script> | {% versioned_script "dist/standalone/api-browser/index-%v.js" %} | ||||||
| <meta name="theme-color" content="#151515" media="(prefers-color-scheme: light)"> | <meta name="theme-color" content="#151515" media="(prefers-color-scheme: light)"> | ||||||
| <meta name="theme-color" content="#151515" media="(prefers-color-scheme: dark)"> | <meta name="theme-color" content="#151515" media="(prefers-color-scheme: dark)"> | ||||||
| {% endblock %} | {% endblock %} | ||||||
|  | |||||||
| @ -11,21 +11,20 @@ from rest_framework.filters import OrderingFilter, SearchFilter | |||||||
| from rest_framework.permissions import AllowAny | from rest_framework.permissions import AllowAny | ||||||
| from rest_framework.request import Request | from rest_framework.request import Request | ||||||
| from rest_framework.response import Response | from rest_framework.response import Response | ||||||
| from rest_framework.serializers import ModelSerializer |  | ||||||
| from rest_framework.validators import UniqueValidator | from rest_framework.validators import UniqueValidator | ||||||
| from rest_framework.viewsets import ModelViewSet | from rest_framework.viewsets import ModelViewSet | ||||||
|  |  | ||||||
| from authentik.api.authorization import SecretKeyFilter | from authentik.api.authorization import SecretKeyFilter | ||||||
| from authentik.brands.models import Brand | from authentik.brands.models import Brand | ||||||
| from authentik.core.api.used_by import UsedByMixin | from authentik.core.api.used_by import UsedByMixin | ||||||
| from authentik.core.api.utils import PassiveSerializer | from authentik.core.api.utils import ModelSerializer, PassiveSerializer | ||||||
| from authentik.tenants.utils import get_current_tenant | from authentik.tenants.utils import get_current_tenant | ||||||
|  |  | ||||||
|  |  | ||||||
| class FooterLinkSerializer(PassiveSerializer): | class FooterLinkSerializer(PassiveSerializer): | ||||||
|     """Links returned in Config API""" |     """Links returned in Config API""" | ||||||
|  |  | ||||||
|     href = CharField(read_only=True) |     href = CharField(read_only=True, allow_null=True) | ||||||
|     name = CharField(read_only=True) |     name = CharField(read_only=True) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | |||||||
| @ -17,7 +17,6 @@ from rest_framework.fields import CharField, ReadOnlyField, SerializerMethodFiel | |||||||
| from rest_framework.parsers import MultiPartParser | from rest_framework.parsers import MultiPartParser | ||||||
| from rest_framework.request import Request | from rest_framework.request import Request | ||||||
| from rest_framework.response import Response | from rest_framework.response import Response | ||||||
| from rest_framework.serializers import ModelSerializer |  | ||||||
| from rest_framework.viewsets import ModelViewSet | from rest_framework.viewsets import ModelViewSet | ||||||
| from structlog.stdlib import get_logger | from structlog.stdlib import get_logger | ||||||
|  |  | ||||||
| @ -26,6 +25,7 @@ from authentik.api.pagination import Pagination | |||||||
| from authentik.blueprints.v1.importer import SERIALIZER_CONTEXT_BLUEPRINT | from authentik.blueprints.v1.importer import SERIALIZER_CONTEXT_BLUEPRINT | ||||||
| from authentik.core.api.providers import ProviderSerializer | from authentik.core.api.providers import ProviderSerializer | ||||||
| from authentik.core.api.used_by import UsedByMixin | from authentik.core.api.used_by import UsedByMixin | ||||||
|  | from authentik.core.api.utils import ModelSerializer | ||||||
| from authentik.core.models import Application, User | from authentik.core.models import Application, User | ||||||
| from authentik.events.logs import LogEventSerializer, capture_logs | from authentik.events.logs import LogEventSerializer, capture_logs | ||||||
| from authentik.events.models import EventAction | from authentik.events.models import EventAction | ||||||
|  | |||||||
| @ -8,12 +8,12 @@ from rest_framework import mixins | |||||||
| from rest_framework.fields import SerializerMethodField | from rest_framework.fields import SerializerMethodField | ||||||
| from rest_framework.filters import OrderingFilter, SearchFilter | from rest_framework.filters import OrderingFilter, SearchFilter | ||||||
| from rest_framework.request import Request | from rest_framework.request import Request | ||||||
| from rest_framework.serializers import ModelSerializer |  | ||||||
| from rest_framework.viewsets import GenericViewSet | from rest_framework.viewsets import GenericViewSet | ||||||
| from ua_parser import user_agent_parser | from ua_parser import user_agent_parser | ||||||
|  |  | ||||||
| from authentik.api.authorization import OwnerSuperuserPermissions | from authentik.api.authorization import OwnerSuperuserPermissions | ||||||
| from authentik.core.api.used_by import UsedByMixin | from authentik.core.api.used_by import UsedByMixin | ||||||
|  | from authentik.core.api.utils import ModelSerializer | ||||||
| from authentik.core.models import AuthenticatedSession | from authentik.core.models import AuthenticatedSession | ||||||
| from authentik.events.context_processors.asn import ASN_CONTEXT_PROCESSOR, ASNDict | from authentik.events.context_processors.asn import ASN_CONTEXT_PROCESSOR, ASNDict | ||||||
| from authentik.events.context_processors.geoip import GEOIP_CONTEXT_PROCESSOR, GeoIPDict | from authentik.events.context_processors.geoip import GEOIP_CONTEXT_PROCESSOR, GeoIPDict | ||||||
|  | |||||||
| @ -17,12 +17,12 @@ from rest_framework.decorators import action | |||||||
| from rest_framework.fields import CharField, IntegerField, SerializerMethodField | from rest_framework.fields import CharField, IntegerField, SerializerMethodField | ||||||
| from rest_framework.request import Request | from rest_framework.request import Request | ||||||
| from rest_framework.response import Response | from rest_framework.response import Response | ||||||
| from rest_framework.serializers import ListSerializer, ModelSerializer, ValidationError | from rest_framework.serializers import ListSerializer, ValidationError | ||||||
| from rest_framework.validators import UniqueValidator | from rest_framework.validators import UniqueValidator | ||||||
| from rest_framework.viewsets import ModelViewSet | from rest_framework.viewsets import ModelViewSet | ||||||
|  |  | ||||||
| from authentik.core.api.used_by import UsedByMixin | from authentik.core.api.used_by import UsedByMixin | ||||||
| from authentik.core.api.utils import JSONDictField, PassiveSerializer | from authentik.core.api.utils import JSONDictField, ModelSerializer, PassiveSerializer | ||||||
| from authentik.core.models import Group, User | from authentik.core.models import Group, User | ||||||
| from authentik.rbac.api.roles import RoleSerializer | from authentik.rbac.api.roles import RoleSerializer | ||||||
| from authentik.rbac.decorators import permission_required | from authentik.rbac.decorators import permission_required | ||||||
|  | |||||||
| @ -8,11 +8,10 @@ from guardian.shortcuts import get_objects_for_user | |||||||
| from rest_framework import mixins | from rest_framework import mixins | ||||||
| from rest_framework.decorators import action | from rest_framework.decorators import action | ||||||
| from rest_framework.exceptions import PermissionDenied | from rest_framework.exceptions import PermissionDenied | ||||||
| from rest_framework.fields import BooleanField, CharField | from rest_framework.fields import BooleanField, CharField, SerializerMethodField | ||||||
| from rest_framework.relations import PrimaryKeyRelatedField | from rest_framework.relations import PrimaryKeyRelatedField | ||||||
| from rest_framework.request import Request | from rest_framework.request import Request | ||||||
| from rest_framework.response import Response | from rest_framework.response import Response | ||||||
| from rest_framework.serializers import ModelSerializer, SerializerMethodField |  | ||||||
| from rest_framework.viewsets import GenericViewSet | from rest_framework.viewsets import GenericViewSet | ||||||
|  |  | ||||||
| from authentik.blueprints.api import ManagedSerializer | from authentik.blueprints.api import ManagedSerializer | ||||||
| @ -20,6 +19,7 @@ from authentik.core.api.object_types import TypesMixin | |||||||
| from authentik.core.api.used_by import UsedByMixin | from authentik.core.api.used_by import UsedByMixin | ||||||
| from authentik.core.api.utils import ( | from authentik.core.api.utils import ( | ||||||
|     MetaNameSerializer, |     MetaNameSerializer, | ||||||
|  |     ModelSerializer, | ||||||
|     PassiveSerializer, |     PassiveSerializer, | ||||||
| ) | ) | ||||||
| from authentik.core.expression.evaluator import PropertyMappingEvaluator | from authentik.core.expression.evaluator import PropertyMappingEvaluator | ||||||
|  | |||||||
| @ -6,13 +6,12 @@ from django.utils.translation import gettext_lazy as _ | |||||||
| from django_filters.filters import BooleanFilter | from django_filters.filters import BooleanFilter | ||||||
| from django_filters.filterset import FilterSet | from django_filters.filterset import FilterSet | ||||||
| from rest_framework import mixins | from rest_framework import mixins | ||||||
| from rest_framework.fields import ReadOnlyField | from rest_framework.fields import ReadOnlyField, SerializerMethodField | ||||||
| from rest_framework.serializers import ModelSerializer, SerializerMethodField |  | ||||||
| from rest_framework.viewsets import GenericViewSet | from rest_framework.viewsets import GenericViewSet | ||||||
|  |  | ||||||
| from authentik.core.api.object_types import TypesMixin | from authentik.core.api.object_types import TypesMixin | ||||||
| from authentik.core.api.used_by import UsedByMixin | from authentik.core.api.used_by import UsedByMixin | ||||||
| from authentik.core.api.utils import MetaNameSerializer | from authentik.core.api.utils import MetaNameSerializer, ModelSerializer | ||||||
| from authentik.core.models import Provider | from authentik.core.models import Provider | ||||||
|  |  | ||||||
|  |  | ||||||
|  | |||||||
| @ -11,7 +11,6 @@ from rest_framework.filters import OrderingFilter, SearchFilter | |||||||
| from rest_framework.parsers import MultiPartParser | from rest_framework.parsers import MultiPartParser | ||||||
| from rest_framework.request import Request | from rest_framework.request import Request | ||||||
| from rest_framework.response import Response | from rest_framework.response import Response | ||||||
| from rest_framework.serializers import ModelSerializer |  | ||||||
| from rest_framework.viewsets import GenericViewSet | from rest_framework.viewsets import GenericViewSet | ||||||
| from structlog.stdlib import get_logger | from structlog.stdlib import get_logger | ||||||
|  |  | ||||||
| @ -19,7 +18,7 @@ from authentik.api.authorization import OwnerFilter, OwnerSuperuserPermissions | |||||||
| from authentik.blueprints.v1.importer import SERIALIZER_CONTEXT_BLUEPRINT | from authentik.blueprints.v1.importer import SERIALIZER_CONTEXT_BLUEPRINT | ||||||
| from authentik.core.api.object_types import TypesMixin | from authentik.core.api.object_types import TypesMixin | ||||||
| from authentik.core.api.used_by import UsedByMixin | from authentik.core.api.used_by import UsedByMixin | ||||||
| from authentik.core.api.utils import MetaNameSerializer | from authentik.core.api.utils import MetaNameSerializer, ModelSerializer | ||||||
| from authentik.core.models import Source, UserSourceConnection | from authentik.core.models import Source, UserSourceConnection | ||||||
| from authentik.core.types import UserSettingSerializer | from authentik.core.types import UserSettingSerializer | ||||||
| from authentik.lib.utils.file import ( | from authentik.lib.utils.file import ( | ||||||
|  | |||||||
| @ -12,7 +12,6 @@ from rest_framework.fields import CharField | |||||||
| from rest_framework.filters import OrderingFilter, SearchFilter | from rest_framework.filters import OrderingFilter, SearchFilter | ||||||
| from rest_framework.request import Request | from rest_framework.request import Request | ||||||
| from rest_framework.response import Response | from rest_framework.response import Response | ||||||
| from rest_framework.serializers import ModelSerializer |  | ||||||
| from rest_framework.viewsets import ModelViewSet | from rest_framework.viewsets import ModelViewSet | ||||||
|  |  | ||||||
| from authentik.api.authorization import OwnerSuperuserPermissions | from authentik.api.authorization import OwnerSuperuserPermissions | ||||||
| @ -20,7 +19,7 @@ from authentik.blueprints.api import ManagedSerializer | |||||||
| from authentik.blueprints.v1.importer import SERIALIZER_CONTEXT_BLUEPRINT | from authentik.blueprints.v1.importer import SERIALIZER_CONTEXT_BLUEPRINT | ||||||
| from authentik.core.api.used_by import UsedByMixin | from authentik.core.api.used_by import UsedByMixin | ||||||
| from authentik.core.api.users import UserSerializer | from authentik.core.api.users import UserSerializer | ||||||
| from authentik.core.api.utils import PassiveSerializer | from authentik.core.api.utils import ModelSerializer, PassiveSerializer | ||||||
| from authentik.core.models import ( | from authentik.core.models import ( | ||||||
|     USER_ATTRIBUTE_TOKEN_EXPIRING, |     USER_ATTRIBUTE_TOKEN_EXPIRING, | ||||||
|     USER_ATTRIBUTE_TOKEN_MAXIMUM_LIFETIME, |     USER_ATTRIBUTE_TOKEN_MAXIMUM_LIFETIME, | ||||||
| @ -45,6 +44,13 @@ class TokenSerializer(ManagedSerializer, ModelSerializer): | |||||||
|         if SERIALIZER_CONTEXT_BLUEPRINT in self.context: |         if SERIALIZER_CONTEXT_BLUEPRINT in self.context: | ||||||
|             self.fields["key"] = CharField(required=False) |             self.fields["key"] = CharField(required=False) | ||||||
|  |  | ||||||
|  |     def validate_user(self, user: User): | ||||||
|  |         """Ensure user of token cannot be changed""" | ||||||
|  |         if self.instance and self.instance.user_id: | ||||||
|  |             if user.pk != self.instance.user_id: | ||||||
|  |                 raise ValidationError("User cannot be changed") | ||||||
|  |         return user | ||||||
|  |  | ||||||
|     def validate(self, attrs: dict[Any, str]) -> dict[Any, str]: |     def validate(self, attrs: dict[Any, str]) -> dict[Any, str]: | ||||||
|         """Ensure only API or App password tokens are created.""" |         """Ensure only API or App password tokens are created.""" | ||||||
|         request: Request = self.context.get("request") |         request: Request = self.context.get("request") | ||||||
|  | |||||||
| @ -40,7 +40,6 @@ from rest_framework.serializers import ( | |||||||
|     BooleanField, |     BooleanField, | ||||||
|     DateTimeField, |     DateTimeField, | ||||||
|     ListSerializer, |     ListSerializer, | ||||||
|     ModelSerializer, |  | ||||||
|     PrimaryKeyRelatedField, |     PrimaryKeyRelatedField, | ||||||
|     ValidationError, |     ValidationError, | ||||||
| ) | ) | ||||||
| @ -52,7 +51,12 @@ from authentik.admin.api.metrics import CoordinateSerializer | |||||||
| from authentik.blueprints.v1.importer import SERIALIZER_CONTEXT_BLUEPRINT | from authentik.blueprints.v1.importer import SERIALIZER_CONTEXT_BLUEPRINT | ||||||
| from authentik.brands.models import Brand | from authentik.brands.models import Brand | ||||||
| from authentik.core.api.used_by import UsedByMixin | from authentik.core.api.used_by import UsedByMixin | ||||||
| from authentik.core.api.utils import JSONDictField, LinkSerializer, PassiveSerializer | from authentik.core.api.utils import ( | ||||||
|  |     JSONDictField, | ||||||
|  |     LinkSerializer, | ||||||
|  |     ModelSerializer, | ||||||
|  |     PassiveSerializer, | ||||||
|  | ) | ||||||
| from authentik.core.middleware import ( | from authentik.core.middleware import ( | ||||||
|     SESSION_KEY_IMPERSONATE_ORIGINAL_USER, |     SESSION_KEY_IMPERSONATE_ORIGINAL_USER, | ||||||
|     SESSION_KEY_IMPERSONATE_USER, |     SESSION_KEY_IMPERSONATE_USER, | ||||||
|  | |||||||
| @ -12,9 +12,12 @@ from rest_framework.fields import ( | |||||||
|     JSONField, |     JSONField, | ||||||
|     SerializerMethodField, |     SerializerMethodField, | ||||||
| ) | ) | ||||||
|  | from rest_framework.serializers import ModelSerializer as BaseModelSerializer | ||||||
| from rest_framework.serializers import ( | from rest_framework.serializers import ( | ||||||
|     Serializer, |     Serializer, | ||||||
|     ValidationError, |     ValidationError, | ||||||
|  |     model_meta, | ||||||
|  |     raise_errors_on_nested_writes, | ||||||
| ) | ) | ||||||
|  |  | ||||||
|  |  | ||||||
| @ -25,6 +28,39 @@ def is_dict(value: Any): | |||||||
|     raise ValidationError("Value must be a dictionary, and not have any duplicate keys.") |     raise ValidationError("Value must be a dictionary, and not have any duplicate keys.") | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class ModelSerializer(BaseModelSerializer): | ||||||
|  |  | ||||||
|  |     def update(self, instance: Model, validated_data): | ||||||
|  |         raise_errors_on_nested_writes("update", self, validated_data) | ||||||
|  |         info = model_meta.get_field_info(instance) | ||||||
|  |  | ||||||
|  |         # Simply set each attribute on the instance, and then save it. | ||||||
|  |         # Note that unlike `.create()` we don't need to treat many-to-many | ||||||
|  |         # relationships as being a special case. During updates we already | ||||||
|  |         # have an instance pk for the relationships to be associated with. | ||||||
|  |         m2m_fields = [] | ||||||
|  |         for attr, value in validated_data.items(): | ||||||
|  |             if attr in info.relations and info.relations[attr].to_many: | ||||||
|  |                 m2m_fields.append((attr, value)) | ||||||
|  |             else: | ||||||
|  |                 setattr(instance, attr, value) | ||||||
|  |  | ||||||
|  |         instance.save() | ||||||
|  |  | ||||||
|  |         # Note that many-to-many fields are set after updating instance. | ||||||
|  |         # Setting m2m fields triggers signals which could potentially change | ||||||
|  |         # updated instance and we do not want it to collide with .update() | ||||||
|  |         for attr, value in m2m_fields: | ||||||
|  |             field = getattr(instance, attr) | ||||||
|  |             # We can't check for inheritance here as m2m managers are generated dynamically | ||||||
|  |             if field.__class__.__name__ == "RelatedManager": | ||||||
|  |                 field.set(value, bulk=False) | ||||||
|  |             else: | ||||||
|  |                 field.set(value) | ||||||
|  |  | ||||||
|  |         return instance | ||||||
|  |  | ||||||
|  |  | ||||||
| class JSONDictField(JSONField): | class JSONDictField(JSONField): | ||||||
|     """JSON Field which only allows dictionaries""" |     """JSON Field which only allows dictionaries""" | ||||||
|  |  | ||||||
|  | |||||||
| @ -76,8 +76,11 @@ class PropertyMappingEvaluator(BaseEvaluator): | |||||||
|         ) |         ) | ||||||
|         if "request" in self._context: |         if "request" in self._context: | ||||||
|             req: PolicyRequest = self._context["request"] |             req: PolicyRequest = self._context["request"] | ||||||
|             event.from_http(req.http_request, req.user) |             if req.http_request: | ||||||
|             return |                 event.from_http(req.http_request, req.user) | ||||||
|  |                 return | ||||||
|  |             elif req.user: | ||||||
|  |                 event.set_user(req.user) | ||||||
|         event.save() |         event.save() | ||||||
|  |  | ||||||
|     def evaluate(self, *args, **kwargs) -> Any: |     def evaluate(self, *args, **kwargs) -> Any: | ||||||
|  | |||||||
| @ -1,5 +1,6 @@ | |||||||
| """authentik core exceptions""" | """authentik core exceptions""" | ||||||
|  |  | ||||||
|  | from authentik.lib.expression.exceptions import ControlFlowException | ||||||
| from authentik.lib.sentry import SentryIgnoredException | from authentik.lib.sentry import SentryIgnoredException | ||||||
|  |  | ||||||
|  |  | ||||||
| @ -12,7 +13,7 @@ class PropertyMappingExpressionException(SentryIgnoredException): | |||||||
|         self.mapping = mapping |         self.mapping = mapping | ||||||
|  |  | ||||||
|  |  | ||||||
| class SkipObjectException(PropertyMappingExpressionException): | class SkipObjectException(ControlFlowException): | ||||||
|     """Exception which can be raised in a property mapping to skip syncing an object. |     """Exception which can be raised in a property mapping to skip syncing an object. | ||||||
|     Only applies to Property mappings which sync objects, and not on mappings which transitively |     Only applies to Property mappings which sync objects, and not on mappings which transitively | ||||||
|     apply to a single user""" |     apply to a single user""" | ||||||
|  | |||||||
| @ -7,12 +7,13 @@ from django.db.backends.base.schema import BaseDatabaseSchemaEditor | |||||||
|  |  | ||||||
|  |  | ||||||
| def backport_is_backchannel(apps: Apps, schema_editor: BaseDatabaseSchemaEditor): | def backport_is_backchannel(apps: Apps, schema_editor: BaseDatabaseSchemaEditor): | ||||||
|  |     db_alias = schema_editor.connection.alias | ||||||
|     from authentik.providers.ldap.models import LDAPProvider |     from authentik.providers.ldap.models import LDAPProvider | ||||||
|     from authentik.providers.scim.models import SCIMProvider |     from authentik.providers.scim.models import SCIMProvider | ||||||
|  |  | ||||||
|     for model in [LDAPProvider, SCIMProvider]: |     for model in [LDAPProvider, SCIMProvider]: | ||||||
|         try: |         try: | ||||||
|             for obj in model.objects.only("is_backchannel"): |             for obj in model.objects.using(db_alias).only("is_backchannel"): | ||||||
|                 obj.is_backchannel = True |                 obj.is_backchannel = True | ||||||
|                 obj.save() |                 obj.save() | ||||||
|         except (DatabaseError, InternalError, ProgrammingError): |         except (DatabaseError, InternalError, ProgrammingError): | ||||||
|  | |||||||
| @ -26,6 +26,7 @@ from authentik.blueprints.models import ManagedModel | |||||||
| from authentik.core.expression.exceptions import PropertyMappingExpressionException | from authentik.core.expression.exceptions import PropertyMappingExpressionException | ||||||
| from authentik.core.types import UILoginButton, UserSettingSerializer | from authentik.core.types import UILoginButton, UserSettingSerializer | ||||||
| from authentik.lib.avatars import get_avatar | from authentik.lib.avatars import get_avatar | ||||||
|  | from authentik.lib.expression.exceptions import ControlFlowException | ||||||
| from authentik.lib.generators import generate_id | from authentik.lib.generators import generate_id | ||||||
| from authentik.lib.models import ( | from authentik.lib.models import ( | ||||||
|     CreatedUpdatedModel, |     CreatedUpdatedModel, | ||||||
| @ -783,6 +784,8 @@ class PropertyMapping(SerializerModel, ManagedModel): | |||||||
|         evaluator = PropertyMappingEvaluator(self, user, request, **kwargs) |         evaluator = PropertyMappingEvaluator(self, user, request, **kwargs) | ||||||
|         try: |         try: | ||||||
|             return evaluator.evaluate(self.expression) |             return evaluator.evaluate(self.expression) | ||||||
|  |         except ControlFlowException as exc: | ||||||
|  |             raise exc | ||||||
|         except Exception as exc: |         except Exception as exc: | ||||||
|             raise PropertyMappingExpressionException(self, exc) from exc |             raise PropertyMappingExpressionException(self, exc) from exc | ||||||
|  |  | ||||||
|  | |||||||
| @ -212,7 +212,7 @@ class SourceFlowManager: | |||||||
|  |  | ||||||
|     def _prepare_flow( |     def _prepare_flow( | ||||||
|         self, |         self, | ||||||
|         flow: Flow, |         flow: Flow | None, | ||||||
|         connection: UserSourceConnection, |         connection: UserSourceConnection, | ||||||
|         stages: list[StageView] | None = None, |         stages: list[StageView] | None = None, | ||||||
|         **kwargs, |         **kwargs, | ||||||
| @ -309,7 +309,9 @@ class SourceFlowManager: | |||||||
|         # When request isn't authenticated we jump straight to auth |         # When request isn't authenticated we jump straight to auth | ||||||
|         if not self.request.user.is_authenticated: |         if not self.request.user.is_authenticated: | ||||||
|             return self.handle_auth(connection) |             return self.handle_auth(connection) | ||||||
|         # Connection has already been saved |         if SESSION_KEY_OVERRIDE_FLOW_TOKEN in self.request.session: | ||||||
|  |             return self._prepare_flow(None, connection) | ||||||
|  |         connection.save() | ||||||
|         Event.new( |         Event.new( | ||||||
|             EventAction.SOURCE_LINKED, |             EventAction.SOURCE_LINKED, | ||||||
|             message="Linked Source", |             message="Linked Source", | ||||||
|  | |||||||
| @ -10,7 +10,7 @@ | |||||||
|         versionSubdomain: "{{ version_subdomain }}", |         versionSubdomain: "{{ version_subdomain }}", | ||||||
|         build: "{{ build }}", |         build: "{{ build }}", | ||||||
|     }; |     }; | ||||||
|     window.addEventListener("DOMContentLoaded", () => { |     window.addEventListener("DOMContentLoaded", function () { | ||||||
|         {% for message in messages %} |         {% for message in messages %} | ||||||
|         window.dispatchEvent( |         window.dispatchEvent( | ||||||
|             new CustomEvent("ak-message", { |             new CustomEvent("ak-message", { | ||||||
|  | |||||||
| @ -1,9 +1,10 @@ | |||||||
| {% load static %} | {% load static %} | ||||||
| {% load i18n %} | {% load i18n %} | ||||||
|  | {% load authentik_core %} | ||||||
|  |  | ||||||
| <!DOCTYPE html> | <!DOCTYPE html> | ||||||
|  |  | ||||||
| <html lang="en"> | <html> | ||||||
|     <head> |     <head> | ||||||
|         <meta charset="UTF-8"> |         <meta charset="UTF-8"> | ||||||
|         <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1"> |         <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1"> | ||||||
| @ -14,8 +15,8 @@ | |||||||
|         {% endblock %} |         {% endblock %} | ||||||
|         <link rel="stylesheet" type="text/css" href="{% static 'dist/authentik.css' %}"> |         <link rel="stylesheet" type="text/css" href="{% static 'dist/authentik.css' %}"> | ||||||
|         <link rel="stylesheet" type="text/css" href="{% static 'dist/custom.css' %}" data-inject> |         <link rel="stylesheet" type="text/css" href="{% static 'dist/custom.css' %}" data-inject> | ||||||
|         <script src="{% static 'dist/poly.js' %}?version={{ version }}" type="module"></script> |         {% versioned_script "dist/poly-%v.js" %} | ||||||
|         <script src="{% static 'dist/standalone/loading/index.js' %}?version={{ version }}" type="module"></script> |         {% versioned_script "dist/standalone/loading/index-%v.js" %} | ||||||
|         {% block head %} |         {% block head %} | ||||||
|         {% endblock %} |         {% endblock %} | ||||||
|         <meta name="sentry-trace" content="{{ sentry_trace }}" /> |         <meta name="sentry-trace" content="{{ sentry_trace }}" /> | ||||||
|  | |||||||
| @ -1,9 +1,9 @@ | |||||||
| {% extends "base/skeleton.html" %} | {% extends "base/skeleton.html" %} | ||||||
|  |  | ||||||
| {% load static %} | {% load authentik_core %} | ||||||
|  |  | ||||||
| {% block head %} | {% block head %} | ||||||
| <script src="{% static 'dist/admin/AdminInterface.js' %}?version={{ version }}" type="module"></script> | {% versioned_script "dist/admin/AdminInterface-%v.js" %} | ||||||
| <meta name="theme-color" content="#18191a" media="(prefers-color-scheme: dark)"> | <meta name="theme-color" content="#18191a" media="(prefers-color-scheme: dark)"> | ||||||
| <meta name="theme-color" content="#ffffff" media="(prefers-color-scheme: light)"> | <meta name="theme-color" content="#ffffff" media="(prefers-color-scheme: light)"> | ||||||
| {% include "base/header_js.html" %} | {% include "base/header_js.html" %} | ||||||
|  | |||||||
| @ -1,9 +1,9 @@ | |||||||
| {% extends "base/skeleton.html" %} | {% extends "base/skeleton.html" %} | ||||||
|  |  | ||||||
| {% load static %} | {% load authentik_core %} | ||||||
|  |  | ||||||
| {% block head %} | {% block head %} | ||||||
| <script src="{% static 'dist/user/UserInterface.js' %}?version={{ version }}" type="module"></script> | {% versioned_script "dist/user/UserInterface-%v.js" %} | ||||||
| <meta name="theme-color" content="#1c1e21" media="(prefers-color-scheme: light)"> | <meta name="theme-color" content="#1c1e21" media="(prefers-color-scheme: light)"> | ||||||
| <meta name="theme-color" content="#1c1e21" media="(prefers-color-scheme: dark)"> | <meta name="theme-color" content="#1c1e21" media="(prefers-color-scheme: dark)"> | ||||||
| {% include "base/header_js.html" %} | {% include "base/header_js.html" %} | ||||||
|  | |||||||
| @ -71,9 +71,9 @@ | |||||||
|                 </li> |                 </li> | ||||||
|                 {% endfor %} |                 {% endfor %} | ||||||
|                 <li> |                 <li> | ||||||
|                     <a href="https://goauthentik.io?utm_source=authentik"> |                     <span> | ||||||
|                         {% trans 'Powered by authentik' %} |                         {% trans 'Powered by authentik' %} | ||||||
|                     </a> |                     </span> | ||||||
|                 </li> |                 </li> | ||||||
|             </ul> |             </ul> | ||||||
|         </footer> |         </footer> | ||||||
|  | |||||||
							
								
								
									
										0
									
								
								authentik/core/templatetags/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								authentik/core/templatetags/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
								
								
									
										21
									
								
								authentik/core/templatetags/authentik_core.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										21
									
								
								authentik/core/templatetags/authentik_core.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,21 @@ | |||||||
|  | """authentik core tags""" | ||||||
|  |  | ||||||
|  | from django import template | ||||||
|  | from django.templatetags.static import static as static_loader | ||||||
|  | from django.utils.safestring import mark_safe | ||||||
|  |  | ||||||
|  | from authentik import get_full_version | ||||||
|  |  | ||||||
|  | register = template.Library() | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @register.simple_tag() | ||||||
|  | def versioned_script(path: str) -> str: | ||||||
|  |     """Wrapper around {% static %} tag that supports setting the version""" | ||||||
|  |     returned_lines = [ | ||||||
|  |         ( | ||||||
|  |             f'<script src="{static_loader(path.replace("%v", get_full_version()))}' | ||||||
|  |             '" type="module"></script>' | ||||||
|  |         ), | ||||||
|  |     ] | ||||||
|  |     return mark_safe("".join(returned_lines))  # nosec | ||||||
| @ -3,7 +3,10 @@ | |||||||
| from django.test import RequestFactory, TestCase | from django.test import RequestFactory, TestCase | ||||||
| from guardian.shortcuts import get_anonymous_user | from guardian.shortcuts import get_anonymous_user | ||||||
|  |  | ||||||
| from authentik.core.expression.exceptions import PropertyMappingExpressionException | from authentik.core.expression.exceptions import ( | ||||||
|  |     PropertyMappingExpressionException, | ||||||
|  |     SkipObjectException, | ||||||
|  | ) | ||||||
| from authentik.core.models import PropertyMapping | from authentik.core.models import PropertyMapping | ||||||
| from authentik.core.tests.utils import create_test_admin_user | from authentik.core.tests.utils import create_test_admin_user | ||||||
| from authentik.events.models import Event, EventAction | from authentik.events.models import Event, EventAction | ||||||
| @ -42,6 +45,17 @@ class TestPropertyMappings(TestCase): | |||||||
|         self.assertTrue(events.exists()) |         self.assertTrue(events.exists()) | ||||||
|         self.assertEqual(len(events), 1) |         self.assertEqual(len(events), 1) | ||||||
|  |  | ||||||
|  |     def test_expression_skip(self): | ||||||
|  |         """Test expression error""" | ||||||
|  |         expr = "raise SkipObject" | ||||||
|  |         mapping = PropertyMapping.objects.create(name=generate_id(), expression=expr) | ||||||
|  |         with self.assertRaises(SkipObjectException): | ||||||
|  |             mapping.evaluate(None, None) | ||||||
|  |         events = Event.objects.filter( | ||||||
|  |             action=EventAction.PROPERTY_MAPPING_EXCEPTION, context__expression=expr | ||||||
|  |         ) | ||||||
|  |         self.assertFalse(events.exists()) | ||||||
|  |  | ||||||
|     def test_expression_error_extended(self): |     def test_expression_error_extended(self): | ||||||
|         """Test expression error (with user and http request""" |         """Test expression error (with user and http request""" | ||||||
|         expr = "return aaa" |         expr = "return aaa" | ||||||
|  | |||||||
| @ -13,9 +13,8 @@ from authentik.core.models import ( | |||||||
|     USER_ATTRIBUTE_TOKEN_MAXIMUM_LIFETIME, |     USER_ATTRIBUTE_TOKEN_MAXIMUM_LIFETIME, | ||||||
|     Token, |     Token, | ||||||
|     TokenIntents, |     TokenIntents, | ||||||
|     User, |  | ||||||
| ) | ) | ||||||
| from authentik.core.tests.utils import create_test_admin_user | from authentik.core.tests.utils import create_test_admin_user, create_test_user | ||||||
| from authentik.lib.generators import generate_id | from authentik.lib.generators import generate_id | ||||||
|  |  | ||||||
|  |  | ||||||
| @ -24,7 +23,7 @@ class TestTokenAPI(APITestCase): | |||||||
|  |  | ||||||
|     def setUp(self) -> None: |     def setUp(self) -> None: | ||||||
|         super().setUp() |         super().setUp() | ||||||
|         self.user = User.objects.create(username="testuser") |         self.user = create_test_user() | ||||||
|         self.admin = create_test_admin_user() |         self.admin = create_test_admin_user() | ||||||
|         self.client.force_login(self.user) |         self.client.force_login(self.user) | ||||||
|  |  | ||||||
| @ -154,6 +153,24 @@ class TestTokenAPI(APITestCase): | |||||||
|         self.assertEqual(token.expiring, True) |         self.assertEqual(token.expiring, True) | ||||||
|         self.assertNotEqual(token.expires.timestamp(), expires.timestamp()) |         self.assertNotEqual(token.expires.timestamp(), expires.timestamp()) | ||||||
|  |  | ||||||
|  |     def test_token_change_user(self): | ||||||
|  |         """Test creating a token and then changing the user""" | ||||||
|  |         ident = generate_id() | ||||||
|  |         response = self.client.post(reverse("authentik_api:token-list"), {"identifier": ident}) | ||||||
|  |         self.assertEqual(response.status_code, 201) | ||||||
|  |         token = Token.objects.get(identifier=ident) | ||||||
|  |         self.assertEqual(token.user, self.user) | ||||||
|  |         self.assertEqual(token.intent, TokenIntents.INTENT_API) | ||||||
|  |         self.assertEqual(token.expiring, True) | ||||||
|  |         self.assertTrue(self.user.has_perm("authentik_core.view_token_key", token)) | ||||||
|  |         response = self.client.put( | ||||||
|  |             reverse("authentik_api:token-detail", kwargs={"identifier": ident}), | ||||||
|  |             data={"identifier": "user_token_poc_v3", "intent": "api", "user": self.admin.pk}, | ||||||
|  |         ) | ||||||
|  |         self.assertEqual(response.status_code, 400) | ||||||
|  |         token.refresh_from_db() | ||||||
|  |         self.assertEqual(token.user, self.user) | ||||||
|  |  | ||||||
|     def test_list(self): |     def test_list(self): | ||||||
|         """Test Token List (Test normal authentication)""" |         """Test Token List (Test normal authentication)""" | ||||||
|         Token.objects.all().delete() |         Token.objects.all().delete() | ||||||
|  | |||||||
| @ -20,8 +20,9 @@ from authentik.core.api.transactional_applications import TransactionalApplicati | |||||||
| from authentik.core.api.users import UserViewSet | from authentik.core.api.users import UserViewSet | ||||||
| from authentik.core.views import apps | from authentik.core.views import apps | ||||||
| from authentik.core.views.debug import AccessDeniedView | from authentik.core.views.debug import AccessDeniedView | ||||||
| from authentik.core.views.interface import FlowInterfaceView, InterfaceView | from authentik.core.views.interface import InterfaceView | ||||||
| from authentik.core.views.session import EndSessionView | from authentik.core.views.session import EndSessionView | ||||||
|  | from authentik.flows.views.interface import FlowInterfaceView | ||||||
| from authentik.root.asgi_middleware import SessionMiddleware | from authentik.root.asgi_middleware import SessionMiddleware | ||||||
| from authentik.root.messages.consumer import MessageConsumer | from authentik.root.messages.consumer import MessageConsumer | ||||||
| from authentik.root.middleware import ChannelsLoggingMiddleware | from authentik.root.middleware import ChannelsLoggingMiddleware | ||||||
| @ -53,6 +54,8 @@ urlpatterns = [ | |||||||
|     ), |     ), | ||||||
|     path( |     path( | ||||||
|         "if/flow/<slug:flow_slug>/", |         "if/flow/<slug:flow_slug>/", | ||||||
|  |         # FIXME: move this url to the flows app...also will cause all | ||||||
|  |         # of the reverse calls to be adjusted | ||||||
|         ensure_csrf_cookie(FlowInterfaceView.as_view()), |         ensure_csrf_cookie(FlowInterfaceView.as_view()), | ||||||
|         name="if-flow", |         name="if-flow", | ||||||
|     ), |     ), | ||||||
|  | |||||||
| @ -3,7 +3,6 @@ | |||||||
| from json import dumps | from json import dumps | ||||||
| from typing import Any | from typing import Any | ||||||
|  |  | ||||||
| from django.shortcuts import get_object_or_404 |  | ||||||
| from django.views.generic.base import TemplateView | from django.views.generic.base import TemplateView | ||||||
| from rest_framework.request import Request | from rest_framework.request import Request | ||||||
|  |  | ||||||
| @ -11,7 +10,6 @@ from authentik import get_build_hash | |||||||
| from authentik.admin.tasks import LOCAL_VERSION | from authentik.admin.tasks import LOCAL_VERSION | ||||||
| from authentik.api.v3.config import ConfigView | from authentik.api.v3.config import ConfigView | ||||||
| from authentik.brands.api import CurrentBrandSerializer | from authentik.brands.api import CurrentBrandSerializer | ||||||
| from authentik.flows.models import Flow |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class InterfaceView(TemplateView): | class InterfaceView(TemplateView): | ||||||
| @ -25,14 +23,3 @@ class InterfaceView(TemplateView): | |||||||
|         kwargs["build"] = get_build_hash() |         kwargs["build"] = get_build_hash() | ||||||
|         kwargs["url_kwargs"] = self.kwargs |         kwargs["url_kwargs"] = self.kwargs | ||||||
|         return super().get_context_data(**kwargs) |         return super().get_context_data(**kwargs) | ||||||
|  |  | ||||||
|  |  | ||||||
| class FlowInterfaceView(InterfaceView): |  | ||||||
|     """Flow interface""" |  | ||||||
|  |  | ||||||
|     template_name = "if/flow.html" |  | ||||||
|  |  | ||||||
|     def get_context_data(self, **kwargs: Any) -> dict[str, Any]: |  | ||||||
|         kwargs["flow"] = get_object_or_404(Flow, slug=self.kwargs.get("flow_slug")) |  | ||||||
|         kwargs["inspector"] = "inspector" in self.request.GET |  | ||||||
|         return super().get_context_data(**kwargs) |  | ||||||
|  | |||||||
| @ -24,13 +24,12 @@ from rest_framework.fields import ( | |||||||
| from rest_framework.filters import OrderingFilter, SearchFilter | from rest_framework.filters import OrderingFilter, SearchFilter | ||||||
| from rest_framework.request import Request | from rest_framework.request import Request | ||||||
| from rest_framework.response import Response | from rest_framework.response import Response | ||||||
| from rest_framework.serializers import ModelSerializer |  | ||||||
| from rest_framework.viewsets import ModelViewSet | from rest_framework.viewsets import ModelViewSet | ||||||
| from structlog.stdlib import get_logger | from structlog.stdlib import get_logger | ||||||
|  |  | ||||||
| from authentik.api.authorization import SecretKeyFilter | from authentik.api.authorization import SecretKeyFilter | ||||||
| from authentik.core.api.used_by import UsedByMixin | from authentik.core.api.used_by import UsedByMixin | ||||||
| from authentik.core.api.utils import PassiveSerializer | from authentik.core.api.utils import ModelSerializer, PassiveSerializer | ||||||
| from authentik.crypto.apps import MANAGED_KEY | from authentik.crypto.apps import MANAGED_KEY | ||||||
| from authentik.crypto.builder import CertificateBuilder, PrivateKeyAlg | from authentik.crypto.builder import CertificateBuilder, PrivateKeyAlg | ||||||
| from authentik.crypto.models import CertificateKeyPair | from authentik.crypto.models import CertificateKeyPair | ||||||
|  | |||||||
| @ -13,11 +13,10 @@ from rest_framework.fields import CharField, IntegerField | |||||||
| from rest_framework.permissions import IsAuthenticated | from rest_framework.permissions import IsAuthenticated | ||||||
| from rest_framework.request import Request | from rest_framework.request import Request | ||||||
| from rest_framework.response import Response | from rest_framework.response import Response | ||||||
| from rest_framework.serializers import ModelSerializer |  | ||||||
| from rest_framework.viewsets import ModelViewSet | from rest_framework.viewsets import ModelViewSet | ||||||
|  |  | ||||||
| from authentik.core.api.used_by import UsedByMixin | from authentik.core.api.used_by import UsedByMixin | ||||||
| from authentik.core.api.utils import PassiveSerializer | from authentik.core.api.utils import ModelSerializer, PassiveSerializer | ||||||
| from authentik.core.models import User, UserTypes | from authentik.core.models import User, UserTypes | ||||||
| from authentik.enterprise.license import LicenseKey, LicenseSummarySerializer | from authentik.enterprise.license import LicenseKey, LicenseSummarySerializer | ||||||
| from authentik.enterprise.models import License | from authentik.enterprise.models import License | ||||||
|  | |||||||
| @ -1,12 +1,13 @@ | |||||||
| """GoogleWorkspaceProviderGroup API Views""" | """GoogleWorkspaceProviderGroup API Views""" | ||||||
|  |  | ||||||
| from rest_framework import mixins | from rest_framework import mixins | ||||||
| from rest_framework.serializers import ModelSerializer |  | ||||||
| from rest_framework.viewsets import GenericViewSet | from rest_framework.viewsets import GenericViewSet | ||||||
|  |  | ||||||
| from authentik.core.api.used_by import UsedByMixin | from authentik.core.api.used_by import UsedByMixin | ||||||
| from authentik.core.api.users import UserGroupSerializer | from authentik.core.api.users import UserGroupSerializer | ||||||
|  | from authentik.core.api.utils import ModelSerializer | ||||||
| from authentik.enterprise.providers.google_workspace.models import GoogleWorkspaceProviderGroup | from authentik.enterprise.providers.google_workspace.models import GoogleWorkspaceProviderGroup | ||||||
|  | from authentik.lib.sync.outgoing.api import OutgoingSyncConnectionCreateMixin | ||||||
|  |  | ||||||
|  |  | ||||||
| class GoogleWorkspaceProviderGroupSerializer(ModelSerializer): | class GoogleWorkspaceProviderGroupSerializer(ModelSerializer): | ||||||
| @ -30,6 +31,7 @@ class GoogleWorkspaceProviderGroupSerializer(ModelSerializer): | |||||||
|  |  | ||||||
| class GoogleWorkspaceProviderGroupViewSet( | class GoogleWorkspaceProviderGroupViewSet( | ||||||
|     mixins.CreateModelMixin, |     mixins.CreateModelMixin, | ||||||
|  |     OutgoingSyncConnectionCreateMixin, | ||||||
|     mixins.RetrieveModelMixin, |     mixins.RetrieveModelMixin, | ||||||
|     mixins.DestroyModelMixin, |     mixins.DestroyModelMixin, | ||||||
|     UsedByMixin, |     UsedByMixin, | ||||||
|  | |||||||
| @ -1,12 +1,13 @@ | |||||||
| """GoogleWorkspaceProviderUser API Views""" | """GoogleWorkspaceProviderUser API Views""" | ||||||
|  |  | ||||||
| from rest_framework import mixins | from rest_framework import mixins | ||||||
| from rest_framework.serializers import ModelSerializer |  | ||||||
| from rest_framework.viewsets import GenericViewSet | from rest_framework.viewsets import GenericViewSet | ||||||
|  |  | ||||||
| from authentik.core.api.groups import GroupMemberSerializer | from authentik.core.api.groups import GroupMemberSerializer | ||||||
| from authentik.core.api.used_by import UsedByMixin | from authentik.core.api.used_by import UsedByMixin | ||||||
|  | from authentik.core.api.utils import ModelSerializer | ||||||
| from authentik.enterprise.providers.google_workspace.models import GoogleWorkspaceProviderUser | from authentik.enterprise.providers.google_workspace.models import GoogleWorkspaceProviderUser | ||||||
|  | from authentik.lib.sync.outgoing.api import OutgoingSyncConnectionCreateMixin | ||||||
|  |  | ||||||
|  |  | ||||||
| class GoogleWorkspaceProviderUserSerializer(ModelSerializer): | class GoogleWorkspaceProviderUserSerializer(ModelSerializer): | ||||||
| @ -30,6 +31,7 @@ class GoogleWorkspaceProviderUserSerializer(ModelSerializer): | |||||||
|  |  | ||||||
| class GoogleWorkspaceProviderUserViewSet( | class GoogleWorkspaceProviderUserViewSet( | ||||||
|     mixins.CreateModelMixin, |     mixins.CreateModelMixin, | ||||||
|  |     OutgoingSyncConnectionCreateMixin, | ||||||
|     mixins.RetrieveModelMixin, |     mixins.RetrieveModelMixin, | ||||||
|     mixins.DestroyModelMixin, |     mixins.DestroyModelMixin, | ||||||
|     UsedByMixin, |     UsedByMixin, | ||||||
|  | |||||||
| @ -214,3 +214,7 @@ class GoogleWorkspaceGroupClient( | |||||||
|             google_id=google_id, |             google_id=google_id, | ||||||
|             attributes=group, |             attributes=group, | ||||||
|         ) |         ) | ||||||
|  |  | ||||||
|  |     def update_single_attribute(self, connection: GoogleWorkspaceProviderUser): | ||||||
|  |         group = self.directory_service.groups().get(connection.google_id) | ||||||
|  |         connection.attributes = group | ||||||
|  | |||||||
| @ -119,3 +119,7 @@ class GoogleWorkspaceUserClient(GoogleWorkspaceSyncClient[User, GoogleWorkspaceP | |||||||
|             google_id=email, |             google_id=email, | ||||||
|             attributes=user, |             attributes=user, | ||||||
|         ) |         ) | ||||||
|  |  | ||||||
|  |     def update_single_attribute(self, connection: GoogleWorkspaceProviderUser): | ||||||
|  |         user = self.directory_service.users().get(connection.google_id) | ||||||
|  |         connection.attributes = user | ||||||
|  | |||||||
| @ -31,6 +31,58 @@ def default_scopes() -> list[str]: | |||||||
|     ] |     ] | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class GoogleWorkspaceProviderUser(SerializerModel): | ||||||
|  |     """Mapping of a user and provider to a Google user ID""" | ||||||
|  |  | ||||||
|  |     id = models.UUIDField(primary_key=True, editable=False, default=uuid4) | ||||||
|  |     google_id = models.TextField() | ||||||
|  |     user = models.ForeignKey(User, on_delete=models.CASCADE) | ||||||
|  |     provider = models.ForeignKey("GoogleWorkspaceProvider", on_delete=models.CASCADE) | ||||||
|  |     attributes = models.JSONField(default=dict) | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def serializer(self) -> type[Serializer]: | ||||||
|  |         from authentik.enterprise.providers.google_workspace.api.users import ( | ||||||
|  |             GoogleWorkspaceProviderUserSerializer, | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |         return GoogleWorkspaceProviderUserSerializer | ||||||
|  |  | ||||||
|  |     class Meta: | ||||||
|  |         verbose_name = _("Google Workspace Provider User") | ||||||
|  |         verbose_name_plural = _("Google Workspace Provider Users") | ||||||
|  |         unique_together = (("google_id", "user", "provider"),) | ||||||
|  |  | ||||||
|  |     def __str__(self) -> str: | ||||||
|  |         return f"Google Workspace Provider User {self.user_id} to {self.provider_id}" | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class GoogleWorkspaceProviderGroup(SerializerModel): | ||||||
|  |     """Mapping of a group and provider to a Google group ID""" | ||||||
|  |  | ||||||
|  |     id = models.UUIDField(primary_key=True, editable=False, default=uuid4) | ||||||
|  |     google_id = models.TextField() | ||||||
|  |     group = models.ForeignKey(Group, on_delete=models.CASCADE) | ||||||
|  |     provider = models.ForeignKey("GoogleWorkspaceProvider", on_delete=models.CASCADE) | ||||||
|  |     attributes = models.JSONField(default=dict) | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def serializer(self) -> type[Serializer]: | ||||||
|  |         from authentik.enterprise.providers.google_workspace.api.groups import ( | ||||||
|  |             GoogleWorkspaceProviderGroupSerializer, | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |         return GoogleWorkspaceProviderGroupSerializer | ||||||
|  |  | ||||||
|  |     class Meta: | ||||||
|  |         verbose_name = _("Google Workspace Provider Group") | ||||||
|  |         verbose_name_plural = _("Google Workspace Provider Groups") | ||||||
|  |         unique_together = (("google_id", "group", "provider"),) | ||||||
|  |  | ||||||
|  |     def __str__(self) -> str: | ||||||
|  |         return f"Google Workspace Provider Group {self.group_id} to {self.provider_id}" | ||||||
|  |  | ||||||
|  |  | ||||||
| class GoogleWorkspaceProvider(OutgoingSyncProvider, BackchannelProvider): | class GoogleWorkspaceProvider(OutgoingSyncProvider, BackchannelProvider): | ||||||
|     """Sync users from authentik into Google Workspace.""" |     """Sync users from authentik into Google Workspace.""" | ||||||
|  |  | ||||||
| @ -59,15 +111,16 @@ class GoogleWorkspaceProvider(OutgoingSyncProvider, BackchannelProvider): | |||||||
|     ) |     ) | ||||||
|  |  | ||||||
|     def client_for_model( |     def client_for_model( | ||||||
|         self, model: type[User | Group] |         self, | ||||||
|  |         model: type[User | Group | GoogleWorkspaceProviderUser | GoogleWorkspaceProviderGroup], | ||||||
|     ) -> BaseOutgoingSyncClient[User | Group, Any, Any, Self]: |     ) -> BaseOutgoingSyncClient[User | Group, Any, Any, Self]: | ||||||
|         if issubclass(model, User): |         if issubclass(model, User | GoogleWorkspaceProviderUser): | ||||||
|             from authentik.enterprise.providers.google_workspace.clients.users import ( |             from authentik.enterprise.providers.google_workspace.clients.users import ( | ||||||
|                 GoogleWorkspaceUserClient, |                 GoogleWorkspaceUserClient, | ||||||
|             ) |             ) | ||||||
|  |  | ||||||
|             return GoogleWorkspaceUserClient(self) |             return GoogleWorkspaceUserClient(self) | ||||||
|         if issubclass(model, Group): |         if issubclass(model, Group | GoogleWorkspaceProviderGroup): | ||||||
|             from authentik.enterprise.providers.google_workspace.clients.groups import ( |             from authentik.enterprise.providers.google_workspace.clients.groups import ( | ||||||
|                 GoogleWorkspaceGroupClient, |                 GoogleWorkspaceGroupClient, | ||||||
|             ) |             ) | ||||||
| @ -144,55 +197,3 @@ class GoogleWorkspaceProviderMapping(PropertyMapping): | |||||||
|     class Meta: |     class Meta: | ||||||
|         verbose_name = _("Google Workspace Provider Mapping") |         verbose_name = _("Google Workspace Provider Mapping") | ||||||
|         verbose_name_plural = _("Google Workspace Provider Mappings") |         verbose_name_plural = _("Google Workspace Provider Mappings") | ||||||
|  |  | ||||||
|  |  | ||||||
| class GoogleWorkspaceProviderUser(SerializerModel): |  | ||||||
|     """Mapping of a user and provider to a Google user ID""" |  | ||||||
|  |  | ||||||
|     id = models.UUIDField(primary_key=True, editable=False, default=uuid4) |  | ||||||
|     google_id = models.TextField() |  | ||||||
|     user = models.ForeignKey(User, on_delete=models.CASCADE) |  | ||||||
|     provider = models.ForeignKey(GoogleWorkspaceProvider, on_delete=models.CASCADE) |  | ||||||
|     attributes = models.JSONField(default=dict) |  | ||||||
|  |  | ||||||
|     @property |  | ||||||
|     def serializer(self) -> type[Serializer]: |  | ||||||
|         from authentik.enterprise.providers.google_workspace.api.users import ( |  | ||||||
|             GoogleWorkspaceProviderUserSerializer, |  | ||||||
|         ) |  | ||||||
|  |  | ||||||
|         return GoogleWorkspaceProviderUserSerializer |  | ||||||
|  |  | ||||||
|     class Meta: |  | ||||||
|         verbose_name = _("Google Workspace Provider User") |  | ||||||
|         verbose_name_plural = _("Google Workspace Provider Users") |  | ||||||
|         unique_together = (("google_id", "user", "provider"),) |  | ||||||
|  |  | ||||||
|     def __str__(self) -> str: |  | ||||||
|         return f"Google Workspace Provider User {self.user_id} to {self.provider_id}" |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class GoogleWorkspaceProviderGroup(SerializerModel): |  | ||||||
|     """Mapping of a group and provider to a Google group ID""" |  | ||||||
|  |  | ||||||
|     id = models.UUIDField(primary_key=True, editable=False, default=uuid4) |  | ||||||
|     google_id = models.TextField() |  | ||||||
|     group = models.ForeignKey(Group, on_delete=models.CASCADE) |  | ||||||
|     provider = models.ForeignKey(GoogleWorkspaceProvider, on_delete=models.CASCADE) |  | ||||||
|     attributes = models.JSONField(default=dict) |  | ||||||
|  |  | ||||||
|     @property |  | ||||||
|     def serializer(self) -> type[Serializer]: |  | ||||||
|         from authentik.enterprise.providers.google_workspace.api.groups import ( |  | ||||||
|             GoogleWorkspaceProviderGroupSerializer, |  | ||||||
|         ) |  | ||||||
|  |  | ||||||
|         return GoogleWorkspaceProviderGroupSerializer |  | ||||||
|  |  | ||||||
|     class Meta: |  | ||||||
|         verbose_name = _("Google Workspace Provider Group") |  | ||||||
|         verbose_name_plural = _("Google Workspace Provider Groups") |  | ||||||
|         unique_together = (("google_id", "group", "provider"),) |  | ||||||
|  |  | ||||||
|     def __str__(self) -> str: |  | ||||||
|         return f"Google Workspace Provider Group {self.group_id} to {self.provider_id}" |  | ||||||
|  | |||||||
| @ -1,12 +1,13 @@ | |||||||
| """MicrosoftEntraProviderGroup API Views""" | """MicrosoftEntraProviderGroup API Views""" | ||||||
|  |  | ||||||
| from rest_framework import mixins | from rest_framework import mixins | ||||||
| from rest_framework.serializers import ModelSerializer |  | ||||||
| from rest_framework.viewsets import GenericViewSet | from rest_framework.viewsets import GenericViewSet | ||||||
|  |  | ||||||
| from authentik.core.api.used_by import UsedByMixin | from authentik.core.api.used_by import UsedByMixin | ||||||
| from authentik.core.api.users import UserGroupSerializer | from authentik.core.api.users import UserGroupSerializer | ||||||
|  | from authentik.core.api.utils import ModelSerializer | ||||||
| from authentik.enterprise.providers.microsoft_entra.models import MicrosoftEntraProviderGroup | from authentik.enterprise.providers.microsoft_entra.models import MicrosoftEntraProviderGroup | ||||||
|  | from authentik.lib.sync.outgoing.api import OutgoingSyncConnectionCreateMixin | ||||||
|  |  | ||||||
|  |  | ||||||
| class MicrosoftEntraProviderGroupSerializer(ModelSerializer): | class MicrosoftEntraProviderGroupSerializer(ModelSerializer): | ||||||
| @ -30,6 +31,7 @@ class MicrosoftEntraProviderGroupSerializer(ModelSerializer): | |||||||
|  |  | ||||||
| class MicrosoftEntraProviderGroupViewSet( | class MicrosoftEntraProviderGroupViewSet( | ||||||
|     mixins.CreateModelMixin, |     mixins.CreateModelMixin, | ||||||
|  |     OutgoingSyncConnectionCreateMixin, | ||||||
|     mixins.RetrieveModelMixin, |     mixins.RetrieveModelMixin, | ||||||
|     mixins.DestroyModelMixin, |     mixins.DestroyModelMixin, | ||||||
|     UsedByMixin, |     UsedByMixin, | ||||||
|  | |||||||
| @ -1,12 +1,13 @@ | |||||||
| """MicrosoftEntraProviderUser API Views""" | """MicrosoftEntraProviderUser API Views""" | ||||||
|  |  | ||||||
| from rest_framework import mixins | from rest_framework import mixins | ||||||
| from rest_framework.serializers import ModelSerializer |  | ||||||
| from rest_framework.viewsets import GenericViewSet | from rest_framework.viewsets import GenericViewSet | ||||||
|  |  | ||||||
| from authentik.core.api.groups import GroupMemberSerializer | from authentik.core.api.groups import GroupMemberSerializer | ||||||
| from authentik.core.api.used_by import UsedByMixin | from authentik.core.api.used_by import UsedByMixin | ||||||
|  | from authentik.core.api.utils import ModelSerializer | ||||||
| from authentik.enterprise.providers.microsoft_entra.models import MicrosoftEntraProviderUser | from authentik.enterprise.providers.microsoft_entra.models import MicrosoftEntraProviderUser | ||||||
|  | from authentik.lib.sync.outgoing.api import OutgoingSyncConnectionCreateMixin | ||||||
|  |  | ||||||
|  |  | ||||||
| class MicrosoftEntraProviderUserSerializer(ModelSerializer): | class MicrosoftEntraProviderUserSerializer(ModelSerializer): | ||||||
| @ -29,6 +30,7 @@ class MicrosoftEntraProviderUserSerializer(ModelSerializer): | |||||||
|  |  | ||||||
|  |  | ||||||
| class MicrosoftEntraProviderUserViewSet( | class MicrosoftEntraProviderUserViewSet( | ||||||
|  |     OutgoingSyncConnectionCreateMixin, | ||||||
|     mixins.CreateModelMixin, |     mixins.CreateModelMixin, | ||||||
|     mixins.RetrieveModelMixin, |     mixins.RetrieveModelMixin, | ||||||
|     mixins.DestroyModelMixin, |     mixins.DestroyModelMixin, | ||||||
|  | |||||||
| @ -226,3 +226,7 @@ class MicrosoftEntraGroupClient( | |||||||
|             microsoft_id=group.id, |             microsoft_id=group.id, | ||||||
|             attributes=self.entity_as_dict(group), |             attributes=self.entity_as_dict(group), | ||||||
|         ) |         ) | ||||||
|  |  | ||||||
|  |     def update_single_attribute(self, connection: MicrosoftEntraProviderGroup): | ||||||
|  |         data = self._request(self.client.groups.by_group_id(connection.microsoft_id).get()) | ||||||
|  |         connection.attributes = self.entity_as_dict(data) | ||||||
|  | |||||||
| @ -66,6 +66,26 @@ class MicrosoftEntraUserClient(MicrosoftEntraSyncClient[User, MicrosoftEntraProv | |||||||
|             microsoft_user.delete() |             microsoft_user.delete() | ||||||
|         return response |         return response | ||||||
|  |  | ||||||
|  |     def get_select_fields(self) -> list[str]: | ||||||
|  |         """All fields that should be selected when we fetch user data.""" | ||||||
|  |         # TODO: Make this customizable in the future | ||||||
|  |         return [ | ||||||
|  |             # Default fields | ||||||
|  |             "businessPhones", | ||||||
|  |             "displayName", | ||||||
|  |             "givenName", | ||||||
|  |             "jobTitle", | ||||||
|  |             "mail", | ||||||
|  |             "mobilePhone", | ||||||
|  |             "officeLocation", | ||||||
|  |             "preferredLanguage", | ||||||
|  |             "surname", | ||||||
|  |             "userPrincipalName", | ||||||
|  |             "id", | ||||||
|  |             # Required for logging into M365 using authentik | ||||||
|  |             "onPremisesImmutableId", | ||||||
|  |         ] | ||||||
|  |  | ||||||
|     def create(self, user: User): |     def create(self, user: User): | ||||||
|         """Create user from scratch and create a connection object""" |         """Create user from scratch and create a connection object""" | ||||||
|         microsoft_user = self.to_schema(user, None) |         microsoft_user = self.to_schema(user, None) | ||||||
| @ -75,12 +95,12 @@ class MicrosoftEntraUserClient(MicrosoftEntraSyncClient[User, MicrosoftEntraProv | |||||||
|                 response = self._request(self.client.users.post(microsoft_user)) |                 response = self._request(self.client.users.post(microsoft_user)) | ||||||
|             except ObjectExistsSyncException: |             except ObjectExistsSyncException: | ||||||
|                 # user already exists in microsoft entra, so we can connect them manually |                 # user already exists in microsoft entra, so we can connect them manually | ||||||
|                 query_params = UsersRequestBuilder.UsersRequestBuilderGetQueryParameters()( |  | ||||||
|                     filter=f"mail eq '{microsoft_user.mail}'", |  | ||||||
|                 ) |  | ||||||
|                 request_configuration = ( |                 request_configuration = ( | ||||||
|                     UsersRequestBuilder.UsersRequestBuilderGetRequestConfiguration( |                     UsersRequestBuilder.UsersRequestBuilderGetRequestConfiguration( | ||||||
|                         query_parameters=query_params, |                         query_parameters=UsersRequestBuilder.UsersRequestBuilderGetQueryParameters( | ||||||
|  |                             filter=f"mail eq '{microsoft_user.mail}'", | ||||||
|  |                             select=self.get_select_fields(), | ||||||
|  |                         ), | ||||||
|                     ) |                     ) | ||||||
|                 ) |                 ) | ||||||
|                 user_data = self._request(self.client.users.get(request_configuration)) |                 user_data = self._request(self.client.users.get(request_configuration)) | ||||||
| @ -99,7 +119,6 @@ class MicrosoftEntraUserClient(MicrosoftEntraSyncClient[User, MicrosoftEntraProv | |||||||
|             except TransientSyncException as exc: |             except TransientSyncException as exc: | ||||||
|                 raise exc |                 raise exc | ||||||
|             else: |             else: | ||||||
|                 print(self.entity_as_dict(response)) |  | ||||||
|                 return MicrosoftEntraProviderUser.objects.create( |                 return MicrosoftEntraProviderUser.objects.create( | ||||||
|                     provider=self.provider, |                     provider=self.provider, | ||||||
|                     user=user, |                     user=user, | ||||||
| @ -120,7 +139,12 @@ class MicrosoftEntraUserClient(MicrosoftEntraSyncClient[User, MicrosoftEntraProv | |||||||
|  |  | ||||||
|     def discover(self): |     def discover(self): | ||||||
|         """Iterate through all users and connect them with authentik users if possible""" |         """Iterate through all users and connect them with authentik users if possible""" | ||||||
|         users = self._request(self.client.users.get()) |         request_configuration = UsersRequestBuilder.UsersRequestBuilderGetRequestConfiguration( | ||||||
|  |             query_parameters=UsersRequestBuilder.UsersRequestBuilderGetQueryParameters( | ||||||
|  |                 select=self.get_select_fields(), | ||||||
|  |             ), | ||||||
|  |         ) | ||||||
|  |         users = self._request(self.client.users.get(request_configuration)) | ||||||
|         next_link = True |         next_link = True | ||||||
|         while next_link: |         while next_link: | ||||||
|             for user in users.value: |             for user in users.value: | ||||||
| @ -141,3 +165,14 @@ class MicrosoftEntraUserClient(MicrosoftEntraSyncClient[User, MicrosoftEntraProv | |||||||
|             microsoft_id=user.id, |             microsoft_id=user.id, | ||||||
|             attributes=self.entity_as_dict(user), |             attributes=self.entity_as_dict(user), | ||||||
|         ) |         ) | ||||||
|  |  | ||||||
|  |     def update_single_attribute(self, connection: MicrosoftEntraProviderUser): | ||||||
|  |         request_configuration = UsersRequestBuilder.UsersRequestBuilderGetRequestConfiguration( | ||||||
|  |             query_parameters=UsersRequestBuilder.UsersRequestBuilderGetQueryParameters( | ||||||
|  |                 select=self.get_select_fields(), | ||||||
|  |             ), | ||||||
|  |         ) | ||||||
|  |         data = self._request( | ||||||
|  |             self.client.users.by_user_id(connection.microsoft_id).get(request_configuration) | ||||||
|  |         ) | ||||||
|  |         connection.attributes = self.entity_as_dict(data) | ||||||
|  | |||||||
| @ -22,6 +22,58 @@ from authentik.lib.sync.outgoing.base import BaseOutgoingSyncClient | |||||||
| from authentik.lib.sync.outgoing.models import OutgoingSyncDeleteAction, OutgoingSyncProvider | from authentik.lib.sync.outgoing.models import OutgoingSyncDeleteAction, OutgoingSyncProvider | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class MicrosoftEntraProviderUser(SerializerModel): | ||||||
|  |     """Mapping of a user and provider to a Microsoft user ID""" | ||||||
|  |  | ||||||
|  |     id = models.UUIDField(primary_key=True, editable=False, default=uuid4) | ||||||
|  |     microsoft_id = models.TextField() | ||||||
|  |     user = models.ForeignKey(User, on_delete=models.CASCADE) | ||||||
|  |     provider = models.ForeignKey("MicrosoftEntraProvider", on_delete=models.CASCADE) | ||||||
|  |     attributes = models.JSONField(default=dict) | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def serializer(self) -> type[Serializer]: | ||||||
|  |         from authentik.enterprise.providers.microsoft_entra.api.users import ( | ||||||
|  |             MicrosoftEntraProviderUserSerializer, | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |         return MicrosoftEntraProviderUserSerializer | ||||||
|  |  | ||||||
|  |     class Meta: | ||||||
|  |         verbose_name = _("Microsoft Entra Provider User") | ||||||
|  |         verbose_name_plural = _("Microsoft Entra Provider User") | ||||||
|  |         unique_together = (("microsoft_id", "user", "provider"),) | ||||||
|  |  | ||||||
|  |     def __str__(self) -> str: | ||||||
|  |         return f"Microsoft Entra Provider User {self.user_id} to {self.provider_id}" | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class MicrosoftEntraProviderGroup(SerializerModel): | ||||||
|  |     """Mapping of a group and provider to a Microsoft group ID""" | ||||||
|  |  | ||||||
|  |     id = models.UUIDField(primary_key=True, editable=False, default=uuid4) | ||||||
|  |     microsoft_id = models.TextField() | ||||||
|  |     group = models.ForeignKey(Group, on_delete=models.CASCADE) | ||||||
|  |     provider = models.ForeignKey("MicrosoftEntraProvider", on_delete=models.CASCADE) | ||||||
|  |     attributes = models.JSONField(default=dict) | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def serializer(self) -> type[Serializer]: | ||||||
|  |         from authentik.enterprise.providers.microsoft_entra.api.groups import ( | ||||||
|  |             MicrosoftEntraProviderGroupSerializer, | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |         return MicrosoftEntraProviderGroupSerializer | ||||||
|  |  | ||||||
|  |     class Meta: | ||||||
|  |         verbose_name = _("Microsoft Entra Provider Group") | ||||||
|  |         verbose_name_plural = _("Microsoft Entra Provider Groups") | ||||||
|  |         unique_together = (("microsoft_id", "group", "provider"),) | ||||||
|  |  | ||||||
|  |     def __str__(self) -> str: | ||||||
|  |         return f"Microsoft Entra Provider Group {self.group_id} to {self.provider_id}" | ||||||
|  |  | ||||||
|  |  | ||||||
| class MicrosoftEntraProvider(OutgoingSyncProvider, BackchannelProvider): | class MicrosoftEntraProvider(OutgoingSyncProvider, BackchannelProvider): | ||||||
|     """Sync users from authentik into Microsoft Entra.""" |     """Sync users from authentik into Microsoft Entra.""" | ||||||
|  |  | ||||||
| @ -48,15 +100,16 @@ class MicrosoftEntraProvider(OutgoingSyncProvider, BackchannelProvider): | |||||||
|     ) |     ) | ||||||
|  |  | ||||||
|     def client_for_model( |     def client_for_model( | ||||||
|         self, model: type[User | Group] |         self, | ||||||
|  |         model: type[User | Group | MicrosoftEntraProviderUser | MicrosoftEntraProviderGroup], | ||||||
|     ) -> BaseOutgoingSyncClient[User | Group, Any, Any, Self]: |     ) -> BaseOutgoingSyncClient[User | Group, Any, Any, Self]: | ||||||
|         if issubclass(model, User): |         if issubclass(model, User | MicrosoftEntraProviderUser): | ||||||
|             from authentik.enterprise.providers.microsoft_entra.clients.users import ( |             from authentik.enterprise.providers.microsoft_entra.clients.users import ( | ||||||
|                 MicrosoftEntraUserClient, |                 MicrosoftEntraUserClient, | ||||||
|             ) |             ) | ||||||
|  |  | ||||||
|             return MicrosoftEntraUserClient(self) |             return MicrosoftEntraUserClient(self) | ||||||
|         if issubclass(model, Group): |         if issubclass(model, Group | MicrosoftEntraProviderGroup): | ||||||
|             from authentik.enterprise.providers.microsoft_entra.clients.groups import ( |             from authentik.enterprise.providers.microsoft_entra.clients.groups import ( | ||||||
|                 MicrosoftEntraGroupClient, |                 MicrosoftEntraGroupClient, | ||||||
|             ) |             ) | ||||||
| @ -133,55 +186,3 @@ class MicrosoftEntraProviderMapping(PropertyMapping): | |||||||
|     class Meta: |     class Meta: | ||||||
|         verbose_name = _("Microsoft Entra Provider Mapping") |         verbose_name = _("Microsoft Entra Provider Mapping") | ||||||
|         verbose_name_plural = _("Microsoft Entra Provider Mappings") |         verbose_name_plural = _("Microsoft Entra Provider Mappings") | ||||||
|  |  | ||||||
|  |  | ||||||
| class MicrosoftEntraProviderUser(SerializerModel): |  | ||||||
|     """Mapping of a user and provider to a Microsoft user ID""" |  | ||||||
|  |  | ||||||
|     id = models.UUIDField(primary_key=True, editable=False, default=uuid4) |  | ||||||
|     microsoft_id = models.TextField() |  | ||||||
|     user = models.ForeignKey(User, on_delete=models.CASCADE) |  | ||||||
|     provider = models.ForeignKey(MicrosoftEntraProvider, on_delete=models.CASCADE) |  | ||||||
|     attributes = models.JSONField(default=dict) |  | ||||||
|  |  | ||||||
|     @property |  | ||||||
|     def serializer(self) -> type[Serializer]: |  | ||||||
|         from authentik.enterprise.providers.microsoft_entra.api.users import ( |  | ||||||
|             MicrosoftEntraProviderUserSerializer, |  | ||||||
|         ) |  | ||||||
|  |  | ||||||
|         return MicrosoftEntraProviderUserSerializer |  | ||||||
|  |  | ||||||
|     class Meta: |  | ||||||
|         verbose_name = _("Microsoft Entra Provider User") |  | ||||||
|         verbose_name_plural = _("Microsoft Entra Provider User") |  | ||||||
|         unique_together = (("microsoft_id", "user", "provider"),) |  | ||||||
|  |  | ||||||
|     def __str__(self) -> str: |  | ||||||
|         return f"Microsoft Entra Provider User {self.user_id} to {self.provider_id}" |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class MicrosoftEntraProviderGroup(SerializerModel): |  | ||||||
|     """Mapping of a group and provider to a Microsoft group ID""" |  | ||||||
|  |  | ||||||
|     id = models.UUIDField(primary_key=True, editable=False, default=uuid4) |  | ||||||
|     microsoft_id = models.TextField() |  | ||||||
|     group = models.ForeignKey(Group, on_delete=models.CASCADE) |  | ||||||
|     provider = models.ForeignKey(MicrosoftEntraProvider, on_delete=models.CASCADE) |  | ||||||
|     attributes = models.JSONField(default=dict) |  | ||||||
|  |  | ||||||
|     @property |  | ||||||
|     def serializer(self) -> type[Serializer]: |  | ||||||
|         from authentik.enterprise.providers.microsoft_entra.api.groups import ( |  | ||||||
|             MicrosoftEntraProviderGroupSerializer, |  | ||||||
|         ) |  | ||||||
|  |  | ||||||
|         return MicrosoftEntraProviderGroupSerializer |  | ||||||
|  |  | ||||||
|     class Meta: |  | ||||||
|         verbose_name = _("Microsoft Entra Provider Group") |  | ||||||
|         verbose_name_plural = _("Microsoft Entra Provider Groups") |  | ||||||
|         unique_together = (("microsoft_id", "group", "provider"),) |  | ||||||
|  |  | ||||||
|     def __str__(self) -> str: |  | ||||||
|         return f"Microsoft Entra Provider Group {self.group_id} to {self.provider_id}" |  | ||||||
|  | |||||||
| @ -3,16 +3,18 @@ | |||||||
| from unittest.mock import AsyncMock, MagicMock, patch | from unittest.mock import AsyncMock, MagicMock, patch | ||||||
|  |  | ||||||
| from azure.identity.aio import ClientSecretCredential | from azure.identity.aio import ClientSecretCredential | ||||||
| from django.test import TestCase | from django.urls import reverse | ||||||
| from msgraph.generated.models.group_collection_response import GroupCollectionResponse | from msgraph.generated.models.group_collection_response import GroupCollectionResponse | ||||||
| from msgraph.generated.models.organization import Organization | from msgraph.generated.models.organization import Organization | ||||||
| from msgraph.generated.models.organization_collection_response import OrganizationCollectionResponse | from msgraph.generated.models.organization_collection_response import OrganizationCollectionResponse | ||||||
| from msgraph.generated.models.user import User as MSUser | from msgraph.generated.models.user import User as MSUser | ||||||
| from msgraph.generated.models.user_collection_response import UserCollectionResponse | from msgraph.generated.models.user_collection_response import UserCollectionResponse | ||||||
| from msgraph.generated.models.verified_domain import VerifiedDomain | from msgraph.generated.models.verified_domain import VerifiedDomain | ||||||
|  | from rest_framework.test import APITestCase | ||||||
|  |  | ||||||
| from authentik.blueprints.tests import apply_blueprint | from authentik.blueprints.tests import apply_blueprint | ||||||
| from authentik.core.models import Application, Group, User | from authentik.core.models import Application, Group, User | ||||||
|  | from authentik.core.tests.utils import create_test_admin_user | ||||||
| from authentik.enterprise.providers.microsoft_entra.models import ( | from authentik.enterprise.providers.microsoft_entra.models import ( | ||||||
|     MicrosoftEntraProvider, |     MicrosoftEntraProvider, | ||||||
|     MicrosoftEntraProviderMapping, |     MicrosoftEntraProviderMapping, | ||||||
| @ -25,11 +27,12 @@ from authentik.lib.sync.outgoing.models import OutgoingSyncDeleteAction | |||||||
| from authentik.tenants.models import Tenant | from authentik.tenants.models import Tenant | ||||||
|  |  | ||||||
|  |  | ||||||
| class MicrosoftEntraUserTests(TestCase): | class MicrosoftEntraUserTests(APITestCase): | ||||||
|     """Microsoft Entra User tests""" |     """Microsoft Entra User tests""" | ||||||
|  |  | ||||||
|     @apply_blueprint("system/providers-microsoft-entra.yaml") |     @apply_blueprint("system/providers-microsoft-entra.yaml") | ||||||
|     def setUp(self) -> None: |     def setUp(self) -> None: | ||||||
|  |  | ||||||
|         # Delete all users and groups as the mocked HTTP responses only return one ID |         # Delete all users and groups as the mocked HTTP responses only return one ID | ||||||
|         # which will cause errors with multiple users |         # which will cause errors with multiple users | ||||||
|         Tenant.objects.update(avatars="none") |         Tenant.objects.update(avatars="none") | ||||||
| @ -371,3 +374,45 @@ class MicrosoftEntraUserTests(TestCase): | |||||||
|             ) |             ) | ||||||
|             self.assertFalse(Event.objects.filter(action=EventAction.SYSTEM_EXCEPTION).exists()) |             self.assertFalse(Event.objects.filter(action=EventAction.SYSTEM_EXCEPTION).exists()) | ||||||
|             user_list.assert_called_once() |             user_list.assert_called_once() | ||||||
|  |  | ||||||
|  |     def test_connect_manual(self): | ||||||
|  |         """test manual user connection""" | ||||||
|  |         uid = generate_id() | ||||||
|  |         self.app.backchannel_providers.remove(self.provider) | ||||||
|  |         admin = create_test_admin_user() | ||||||
|  |         different_user = User.objects.create( | ||||||
|  |             username=uid, | ||||||
|  |             email=f"{uid}@goauthentik.io", | ||||||
|  |         ) | ||||||
|  |         self.app.backchannel_providers.add(self.provider) | ||||||
|  |         with ( | ||||||
|  |             patch( | ||||||
|  |                 "authentik.enterprise.providers.microsoft_entra.models.MicrosoftEntraProvider.microsoft_credentials", | ||||||
|  |                 MagicMock(return_value={"credentials": self.creds}), | ||||||
|  |             ), | ||||||
|  |             patch( | ||||||
|  |                 "msgraph.generated.organization.organization_request_builder.OrganizationRequestBuilder.get", | ||||||
|  |                 AsyncMock( | ||||||
|  |                     return_value=OrganizationCollectionResponse( | ||||||
|  |                         value=[ | ||||||
|  |                             Organization(verified_domains=[VerifiedDomain(name="goauthentik.io")]) | ||||||
|  |                         ] | ||||||
|  |                     ) | ||||||
|  |                 ), | ||||||
|  |             ), | ||||||
|  |             patch( | ||||||
|  |                 "authentik.enterprise.providers.microsoft_entra.clients.users.MicrosoftEntraUserClient.update_single_attribute", | ||||||
|  |                 MagicMock(), | ||||||
|  |             ) as user_get, | ||||||
|  |         ): | ||||||
|  |             self.client.force_login(admin) | ||||||
|  |             response = self.client.post( | ||||||
|  |                 reverse("authentik_api:microsoftentraprovideruser-list"), | ||||||
|  |                 data={ | ||||||
|  |                     "microsoft_id": generate_id(), | ||||||
|  |                     "user": different_user.pk, | ||||||
|  |                     "provider": self.provider.pk, | ||||||
|  |                 }, | ||||||
|  |             ) | ||||||
|  |             self.assertEqual(response.status_code, 201) | ||||||
|  |             user_get.assert_called_once() | ||||||
|  | |||||||
| @ -3,12 +3,12 @@ | |||||||
| from django_filters.rest_framework.backends import DjangoFilterBackend | from django_filters.rest_framework.backends import DjangoFilterBackend | ||||||
| from rest_framework import mixins | from rest_framework import mixins | ||||||
| from rest_framework.filters import OrderingFilter, SearchFilter | from rest_framework.filters import OrderingFilter, SearchFilter | ||||||
| from rest_framework.serializers import ModelSerializer |  | ||||||
| from rest_framework.viewsets import GenericViewSet | from rest_framework.viewsets import GenericViewSet | ||||||
|  |  | ||||||
| from authentik.api.authorization import OwnerFilter, OwnerSuperuserPermissions | from authentik.api.authorization import OwnerFilter, OwnerSuperuserPermissions | ||||||
| from authentik.core.api.groups import GroupMemberSerializer | from authentik.core.api.groups import GroupMemberSerializer | ||||||
| from authentik.core.api.used_by import UsedByMixin | from authentik.core.api.used_by import UsedByMixin | ||||||
|  | from authentik.core.api.utils import ModelSerializer | ||||||
| from authentik.enterprise.api import EnterpriseRequiredMixin | from authentik.enterprise.api import EnterpriseRequiredMixin | ||||||
| from authentik.enterprise.providers.rac.api.endpoints import EndpointSerializer | from authentik.enterprise.providers.rac.api.endpoints import EndpointSerializer | ||||||
| from authentik.enterprise.providers.rac.api.providers import RACProviderSerializer | from authentik.enterprise.providers.rac.api.providers import RACProviderSerializer | ||||||
|  | |||||||
| @ -8,11 +8,11 @@ from drf_spectacular.utils import OpenApiParameter, OpenApiResponse, extend_sche | |||||||
| from rest_framework.fields import SerializerMethodField | from rest_framework.fields import SerializerMethodField | ||||||
| from rest_framework.request import Request | from rest_framework.request import Request | ||||||
| from rest_framework.response import Response | from rest_framework.response import Response | ||||||
| from rest_framework.serializers import ModelSerializer |  | ||||||
| from rest_framework.viewsets import ModelViewSet | from rest_framework.viewsets import ModelViewSet | ||||||
| from structlog.stdlib import get_logger | from structlog.stdlib import get_logger | ||||||
|  |  | ||||||
| from authentik.core.api.used_by import UsedByMixin | from authentik.core.api.used_by import UsedByMixin | ||||||
|  | from authentik.core.api.utils import ModelSerializer | ||||||
| from authentik.core.models import Provider | from authentik.core.models import Provider | ||||||
| from authentik.enterprise.api import EnterpriseRequiredMixin | from authentik.enterprise.api import EnterpriseRequiredMixin | ||||||
| from authentik.enterprise.providers.rac.api.providers import RACProviderSerializer | from authentik.enterprise.providers.rac.api.providers import RACProviderSerializer | ||||||
|  | |||||||
| @ -1,9 +1,9 @@ | |||||||
| {% extends "base/skeleton.html" %} | {% extends "base/skeleton.html" %} | ||||||
|  |  | ||||||
| {% load static %} | {% load authentik_core %} | ||||||
|  |  | ||||||
| {% block head %} | {% block head %} | ||||||
| <script src="{% static 'dist/enterprise/rac/index.js' %}?version={{ version }}" type="module"></script> | {% versioned_script "dist/enterprise/rac/index-%v.js" %} | ||||||
| <meta name="theme-color" content="#18191a" media="(prefers-color-scheme: dark)"> | <meta name="theme-color" content="#18191a" media="(prefers-color-scheme: dark)"> | ||||||
| <meta name="theme-color" content="#ffffff" media="(prefers-color-scheme: light)"> | <meta name="theme-color" content="#ffffff" media="(prefers-color-scheme: light)"> | ||||||
| <link rel="icon" href="{{ tenant.branding_favicon }}"> | <link rel="icon" href="{{ tenant.branding_favicon }}"> | ||||||
|  | |||||||
| @ -5,7 +5,6 @@ from channels.sessions import CookieMiddleware | |||||||
| from django.urls import path | from django.urls import path | ||||||
| from django.views.decorators.csrf import ensure_csrf_cookie | from django.views.decorators.csrf import ensure_csrf_cookie | ||||||
|  |  | ||||||
| from authentik.core.channels import TokenOutpostMiddleware |  | ||||||
| from authentik.enterprise.providers.rac.api.connection_tokens import ConnectionTokenViewSet | from authentik.enterprise.providers.rac.api.connection_tokens import ConnectionTokenViewSet | ||||||
| from authentik.enterprise.providers.rac.api.endpoints import EndpointViewSet | from authentik.enterprise.providers.rac.api.endpoints import EndpointViewSet | ||||||
| from authentik.enterprise.providers.rac.api.property_mappings import RACPropertyMappingViewSet | from authentik.enterprise.providers.rac.api.property_mappings import RACPropertyMappingViewSet | ||||||
| @ -13,6 +12,7 @@ from authentik.enterprise.providers.rac.api.providers import RACProviderViewSet | |||||||
| from authentik.enterprise.providers.rac.consumer_client import RACClientConsumer | from authentik.enterprise.providers.rac.consumer_client import RACClientConsumer | ||||||
| from authentik.enterprise.providers.rac.consumer_outpost import RACOutpostConsumer | from authentik.enterprise.providers.rac.consumer_outpost import RACOutpostConsumer | ||||||
| from authentik.enterprise.providers.rac.views import RACInterface, RACStartView | from authentik.enterprise.providers.rac.views import RACInterface, RACStartView | ||||||
|  | from authentik.outposts.channels import TokenOutpostMiddleware | ||||||
| from authentik.root.asgi_middleware import SessionMiddleware | from authentik.root.asgi_middleware import SessionMiddleware | ||||||
| from authentik.root.middleware import ChannelsLoggingMiddleware | from authentik.root.middleware import ChannelsLoggingMiddleware | ||||||
|  |  | ||||||
|  | |||||||
| @ -15,12 +15,11 @@ from rest_framework.decorators import action | |||||||
| from rest_framework.fields import DictField, IntegerField | from rest_framework.fields import DictField, IntegerField | ||||||
| from rest_framework.request import Request | from rest_framework.request import Request | ||||||
| from rest_framework.response import Response | from rest_framework.response import Response | ||||||
| from rest_framework.serializers import ModelSerializer |  | ||||||
| from rest_framework.viewsets import ModelViewSet | from rest_framework.viewsets import ModelViewSet | ||||||
|  |  | ||||||
| from authentik.admin.api.metrics import CoordinateSerializer | from authentik.admin.api.metrics import CoordinateSerializer | ||||||
| from authentik.core.api.object_types import TypeCreateSerializer | from authentik.core.api.object_types import TypeCreateSerializer | ||||||
| from authentik.core.api.utils import PassiveSerializer | from authentik.core.api.utils import ModelSerializer, PassiveSerializer | ||||||
| from authentik.events.models import Event, EventAction | from authentik.events.models import Event, EventAction | ||||||
|  |  | ||||||
|  |  | ||||||
|  | |||||||
| @ -1,9 +1,9 @@ | |||||||
| """NotificationWebhookMapping API Views""" | """NotificationWebhookMapping API Views""" | ||||||
|  |  | ||||||
| from rest_framework.serializers import ModelSerializer |  | ||||||
| from rest_framework.viewsets import ModelViewSet | from rest_framework.viewsets import ModelViewSet | ||||||
|  |  | ||||||
| from authentik.core.api.used_by import UsedByMixin | from authentik.core.api.used_by import UsedByMixin | ||||||
|  | from authentik.core.api.utils import ModelSerializer | ||||||
| from authentik.events.models import NotificationWebhookMapping | from authentik.events.models import NotificationWebhookMapping | ||||||
|  |  | ||||||
|  |  | ||||||
|  | |||||||
| @ -1,10 +1,10 @@ | |||||||
| """NotificationRule API Views""" | """NotificationRule API Views""" | ||||||
|  |  | ||||||
| from rest_framework.serializers import ModelSerializer |  | ||||||
| from rest_framework.viewsets import ModelViewSet | from rest_framework.viewsets import ModelViewSet | ||||||
|  |  | ||||||
| from authentik.core.api.groups import GroupSerializer | from authentik.core.api.groups import GroupSerializer | ||||||
| from authentik.core.api.used_by import UsedByMixin | from authentik.core.api.used_by import UsedByMixin | ||||||
|  | from authentik.core.api.utils import ModelSerializer | ||||||
| from authentik.events.models import NotificationRule | from authentik.events.models import NotificationRule | ||||||
|  |  | ||||||
|  |  | ||||||
|  | |||||||
| @ -9,11 +9,10 @@ from rest_framework.exceptions import ValidationError | |||||||
| from rest_framework.fields import CharField, ListField, SerializerMethodField | from rest_framework.fields import CharField, ListField, SerializerMethodField | ||||||
| from rest_framework.request import Request | from rest_framework.request import Request | ||||||
| from rest_framework.response import Response | from rest_framework.response import Response | ||||||
| from rest_framework.serializers import ModelSerializer |  | ||||||
| from rest_framework.viewsets import ModelViewSet | from rest_framework.viewsets import ModelViewSet | ||||||
|  |  | ||||||
| from authentik.core.api.used_by import UsedByMixin | from authentik.core.api.used_by import UsedByMixin | ||||||
| from authentik.core.api.utils import PassiveSerializer | from authentik.core.api.utils import ModelSerializer, PassiveSerializer | ||||||
| from authentik.events.models import ( | from authentik.events.models import ( | ||||||
|     Event, |     Event, | ||||||
|     Notification, |     Notification, | ||||||
|  | |||||||
| @ -9,11 +9,11 @@ from rest_framework.fields import ReadOnlyField | |||||||
| from rest_framework.filters import OrderingFilter, SearchFilter | from rest_framework.filters import OrderingFilter, SearchFilter | ||||||
| from rest_framework.request import Request | from rest_framework.request import Request | ||||||
| from rest_framework.response import Response | from rest_framework.response import Response | ||||||
| from rest_framework.serializers import ModelSerializer |  | ||||||
| from rest_framework.viewsets import GenericViewSet | from rest_framework.viewsets import GenericViewSet | ||||||
|  |  | ||||||
| from authentik.api.authorization import OwnerFilter, OwnerPermissions | from authentik.api.authorization import OwnerFilter, OwnerPermissions | ||||||
| from authentik.core.api.used_by import UsedByMixin | from authentik.core.api.used_by import UsedByMixin | ||||||
|  | from authentik.core.api.utils import ModelSerializer | ||||||
| from authentik.events.api.events import EventSerializer | from authentik.events.api.events import EventSerializer | ||||||
| from authentik.events.models import Notification | from authentik.events.models import Notification | ||||||
|  |  | ||||||
|  | |||||||
| @ -16,10 +16,10 @@ from rest_framework.fields import ( | |||||||
| ) | ) | ||||||
| from rest_framework.request import Request | from rest_framework.request import Request | ||||||
| from rest_framework.response import Response | from rest_framework.response import Response | ||||||
| from rest_framework.serializers import ModelSerializer |  | ||||||
| from rest_framework.viewsets import ReadOnlyModelViewSet | from rest_framework.viewsets import ReadOnlyModelViewSet | ||||||
| from structlog.stdlib import get_logger | from structlog.stdlib import get_logger | ||||||
|  |  | ||||||
|  | from authentik.core.api.utils import ModelSerializer | ||||||
| from authentik.events.logs import LogEventSerializer | from authentik.events.logs import LogEventSerializer | ||||||
| from authentik.events.models import SystemTask, TaskStatus | from authentik.events.models import SystemTask, TaskStatus | ||||||
| from authentik.rbac.decorators import permission_required | from authentik.rbac.decorators import permission_required | ||||||
|  | |||||||
| @ -35,6 +35,7 @@ IGNORED_MODELS = tuple( | |||||||
|  |  | ||||||
| _CTX_OVERWRITE_USER = ContextVar[User | None]("authentik_events_log_overwrite_user", default=None) | _CTX_OVERWRITE_USER = ContextVar[User | None]("authentik_events_log_overwrite_user", default=None) | ||||||
| _CTX_IGNORE = ContextVar[bool]("authentik_events_log_ignore", default=False) | _CTX_IGNORE = ContextVar[bool]("authentik_events_log_ignore", default=False) | ||||||
|  | _CTX_REQUEST = ContextVar[HttpRequest | None]("authentik_events_log_request", default=None) | ||||||
|  |  | ||||||
|  |  | ||||||
| def should_log_model(model: Model) -> bool: | def should_log_model(model: Model) -> bool: | ||||||
| @ -149,11 +150,13 @@ class AuditMiddleware: | |||||||
|         m2m_changed.disconnect(dispatch_uid=request.request_id) |         m2m_changed.disconnect(dispatch_uid=request.request_id) | ||||||
|  |  | ||||||
|     def __call__(self, request: HttpRequest) -> HttpResponse: |     def __call__(self, request: HttpRequest) -> HttpResponse: | ||||||
|  |         _CTX_REQUEST.set(request) | ||||||
|         self.connect(request) |         self.connect(request) | ||||||
|  |  | ||||||
|         response = self.get_response(request) |         response = self.get_response(request) | ||||||
|  |  | ||||||
|         self.disconnect(request) |         self.disconnect(request) | ||||||
|  |         _CTX_REQUEST.set(None) | ||||||
|         return response |         return response | ||||||
|  |  | ||||||
|     def process_exception(self, request: HttpRequest, exception: Exception): |     def process_exception(self, request: HttpRequest, exception: Exception): | ||||||
| @ -167,7 +170,7 @@ class AuditMiddleware: | |||||||
|             thread = EventNewThread( |             thread = EventNewThread( | ||||||
|                 EventAction.SUSPICIOUS_REQUEST, |                 EventAction.SUSPICIOUS_REQUEST, | ||||||
|                 request, |                 request, | ||||||
|                 message=str(exception), |                 message=exception_to_string(exception), | ||||||
|             ) |             ) | ||||||
|             thread.run() |             thread.run() | ||||||
|         elif before_send({}, {"exc_info": (None, exception, None)}) is not None: |         elif before_send({}, {"exc_info": (None, exception, None)}) is not None: | ||||||
| @ -192,6 +195,8 @@ class AuditMiddleware: | |||||||
|             return |             return | ||||||
|         if _CTX_IGNORE.get(): |         if _CTX_IGNORE.get(): | ||||||
|             return |             return | ||||||
|  |         if request.request_id != _CTX_REQUEST.get().request_id: | ||||||
|  |             return | ||||||
|         user = self.get_user(request) |         user = self.get_user(request) | ||||||
|  |  | ||||||
|         action = EventAction.MODEL_CREATED if created else EventAction.MODEL_UPDATED |         action = EventAction.MODEL_CREATED if created else EventAction.MODEL_UPDATED | ||||||
| @ -205,6 +210,8 @@ class AuditMiddleware: | |||||||
|             return |             return | ||||||
|         if _CTX_IGNORE.get(): |         if _CTX_IGNORE.get(): | ||||||
|             return |             return | ||||||
|  |         if request.request_id != _CTX_REQUEST.get().request_id: | ||||||
|  |             return | ||||||
|         user = self.get_user(request) |         user = self.get_user(request) | ||||||
|  |  | ||||||
|         EventNewThread( |         EventNewThread( | ||||||
| @ -230,6 +237,8 @@ class AuditMiddleware: | |||||||
|             return |             return | ||||||
|         if _CTX_IGNORE.get(): |         if _CTX_IGNORE.get(): | ||||||
|             return |             return | ||||||
|  |         if request.request_id != _CTX_REQUEST.get().request_id: | ||||||
|  |             return | ||||||
|         user = self.get_user(request) |         user = self.get_user(request) | ||||||
|  |  | ||||||
|         EventNewThread( |         EventNewThread( | ||||||
|  | |||||||
| @ -238,6 +238,8 @@ class Event(SerializerModel, ExpiringModel): | |||||||
|                 "args": cleanse_dict(QueryDict(request.META.get("QUERY_STRING", ""))), |                 "args": cleanse_dict(QueryDict(request.META.get("QUERY_STRING", ""))), | ||||||
|                 "user_agent": request.META.get("HTTP_USER_AGENT", ""), |                 "user_agent": request.META.get("HTTP_USER_AGENT", ""), | ||||||
|             } |             } | ||||||
|  |             if hasattr(request, "request_id"): | ||||||
|  |                 self.context["http_request"]["request_id"] = request.request_id | ||||||
|             # Special case for events created during flow execution |             # Special case for events created during flow execution | ||||||
|             # since they keep the http query within a wrapped query |             # since they keep the http query within a wrapped query | ||||||
|             if QS_QUERY in self.context["http_request"]["args"]: |             if QS_QUERY in self.context["http_request"]["args"]: | ||||||
|  | |||||||
| @ -75,7 +75,10 @@ def on_login_failed( | |||||||
|     **kwargs, |     **kwargs, | ||||||
| ): | ): | ||||||
|     """Failed Login, authentik custom event""" |     """Failed Login, authentik custom event""" | ||||||
|     Event.new(EventAction.LOGIN_FAILED, **credentials, stage=stage, **kwargs).from_http(request) |     user = User.objects.filter(username=credentials.get("username")).first() | ||||||
|  |     Event.new(EventAction.LOGIN_FAILED, **credentials, stage=stage, **kwargs).from_http( | ||||||
|  |         request, user | ||||||
|  |     ) | ||||||
|  |  | ||||||
|  |  | ||||||
| @receiver(invitation_used) | @receiver(invitation_used) | ||||||
|  | |||||||
| @ -3,10 +3,10 @@ | |||||||
| from typing import Any | from typing import Any | ||||||
|  |  | ||||||
| from rest_framework.exceptions import ValidationError | from rest_framework.exceptions import ValidationError | ||||||
| from rest_framework.serializers import ModelSerializer |  | ||||||
| from rest_framework.viewsets import ModelViewSet | from rest_framework.viewsets import ModelViewSet | ||||||
|  |  | ||||||
| from authentik.core.api.used_by import UsedByMixin | from authentik.core.api.used_by import UsedByMixin | ||||||
|  | from authentik.core.api.utils import ModelSerializer | ||||||
| from authentik.flows.api.stages import StageSerializer | from authentik.flows.api.stages import StageSerializer | ||||||
| from authentik.flows.models import FlowStageBinding | from authentik.flows.models import FlowStageBinding | ||||||
|  |  | ||||||
|  | |||||||
| @ -7,18 +7,22 @@ from django.utils.translation import gettext as _ | |||||||
| from drf_spectacular.types import OpenApiTypes | from drf_spectacular.types import OpenApiTypes | ||||||
| from drf_spectacular.utils import OpenApiResponse, extend_schema | from drf_spectacular.utils import OpenApiResponse, extend_schema | ||||||
| from rest_framework.decorators import action | from rest_framework.decorators import action | ||||||
| from rest_framework.fields import BooleanField, CharField, ReadOnlyField | from rest_framework.fields import BooleanField, CharField, ReadOnlyField, SerializerMethodField | ||||||
| from rest_framework.parsers import MultiPartParser | from rest_framework.parsers import MultiPartParser | ||||||
| from rest_framework.request import Request | from rest_framework.request import Request | ||||||
| from rest_framework.response import Response | from rest_framework.response import Response | ||||||
| from rest_framework.serializers import ModelSerializer, SerializerMethodField |  | ||||||
| from rest_framework.viewsets import ModelViewSet | from rest_framework.viewsets import ModelViewSet | ||||||
| from structlog.stdlib import get_logger | from structlog.stdlib import get_logger | ||||||
|  |  | ||||||
| from authentik.blueprints.v1.exporter import FlowExporter | from authentik.blueprints.v1.exporter import FlowExporter | ||||||
| from authentik.blueprints.v1.importer import SERIALIZER_CONTEXT_BLUEPRINT, Importer | from authentik.blueprints.v1.importer import SERIALIZER_CONTEXT_BLUEPRINT, Importer | ||||||
| from authentik.core.api.used_by import UsedByMixin | from authentik.core.api.used_by import UsedByMixin | ||||||
| from authentik.core.api.utils import CacheSerializer, LinkSerializer, PassiveSerializer | from authentik.core.api.utils import ( | ||||||
|  |     CacheSerializer, | ||||||
|  |     LinkSerializer, | ||||||
|  |     ModelSerializer, | ||||||
|  |     PassiveSerializer, | ||||||
|  | ) | ||||||
| from authentik.events.logs import LogEventSerializer | from authentik.events.logs import LogEventSerializer | ||||||
| from authentik.flows.api.flows_diagram import FlowDiagram, FlowDiagramSerializer | from authentik.flows.api.flows_diagram import FlowDiagram, FlowDiagramSerializer | ||||||
| from authentik.flows.exceptions import FlowNonApplicableException | from authentik.flows.exceptions import FlowNonApplicableException | ||||||
|  | |||||||
| @ -4,15 +4,15 @@ from django.urls.base import reverse | |||||||
| from drf_spectacular.utils import extend_schema | from drf_spectacular.utils import extend_schema | ||||||
| from rest_framework import mixins | from rest_framework import mixins | ||||||
| from rest_framework.decorators import action | from rest_framework.decorators import action | ||||||
|  | from rest_framework.fields import SerializerMethodField | ||||||
| from rest_framework.request import Request | from rest_framework.request import Request | ||||||
| from rest_framework.response import Response | from rest_framework.response import Response | ||||||
| from rest_framework.serializers import ModelSerializer, SerializerMethodField |  | ||||||
| from rest_framework.viewsets import GenericViewSet | from rest_framework.viewsets import GenericViewSet | ||||||
| from structlog.stdlib import get_logger | from structlog.stdlib import get_logger | ||||||
|  |  | ||||||
| from authentik.core.api.object_types import TypesMixin | from authentik.core.api.object_types import TypesMixin | ||||||
| from authentik.core.api.used_by import UsedByMixin | from authentik.core.api.used_by import UsedByMixin | ||||||
| from authentik.core.api.utils import MetaNameSerializer | from authentik.core.api.utils import MetaNameSerializer, ModelSerializer | ||||||
| from authentik.core.types import UserSettingSerializer | from authentik.core.types import UserSettingSerializer | ||||||
| from authentik.flows.api.flows import FlowSetSerializer | from authentik.flows.api.flows import FlowSetSerializer | ||||||
| from authentik.flows.models import ConfigurableStage, Stage | from authentik.flows.models import ConfigurableStage, Stage | ||||||
|  | |||||||
| @ -21,7 +21,9 @@ def set_oobe_flow_authentication(apps: Apps, schema_editor: BaseDatabaseSchemaEd | |||||||
|         pass |         pass | ||||||
|  |  | ||||||
|     if users.exists(): |     if users.exists(): | ||||||
|         Flow.objects.filter(slug="initial-setup").update(authentication="require_superuser") |         Flow.objects.using(db_alias).filter(slug="initial-setup").update( | ||||||
|  |             authentication="require_superuser" | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |  | ||||||
| class Migration(migrations.Migration): | class Migration(migrations.Migration): | ||||||
|  | |||||||
							
								
								
									
										54
									
								
								authentik/flows/templates/if/flow-sfe.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										54
									
								
								authentik/flows/templates/if/flow-sfe.html
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,54 @@ | |||||||
|  | {% load static %} | ||||||
|  | {% load i18n %} | ||||||
|  | {% load authentik_core %} | ||||||
|  |  | ||||||
|  | <!DOCTYPE html> | ||||||
|  |  | ||||||
|  | <html lang="en"> | ||||||
|  |     <head> | ||||||
|  |         <meta charset="UTF-8"> | ||||||
|  |         <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1"> | ||||||
|  |         <title>{% block title %}{% trans title|default:brand.branding_title %}{% endblock %}</title> | ||||||
|  |         <link rel="icon" href="{{ brand.branding_favicon }}"> | ||||||
|  |         <link rel="shortcut icon" href="{{ brand.branding_favicon }}"> | ||||||
|  |         {% block head_before %} | ||||||
|  |         {% endblock %} | ||||||
|  |         <link rel="stylesheet" type="text/css" href="{% static 'dist/sfe/bootstrap.min.css' %}"> | ||||||
|  |         <meta name="sentry-trace" content="{{ sentry_trace }}" /> | ||||||
|  |         {% include "base/header_js.html" %} | ||||||
|  |         <style> | ||||||
|  |           html, | ||||||
|  |           body { | ||||||
|  |             height: 100%; | ||||||
|  |           } | ||||||
|  |           body { | ||||||
|  |             background-image: url("{{ flow.background_url }}"); | ||||||
|  |             background-repeat: no-repeat; | ||||||
|  |             background-size: cover; | ||||||
|  |           } | ||||||
|  |           .card { | ||||||
|  |             padding: 3rem; | ||||||
|  |           } | ||||||
|  |  | ||||||
|  |           .form-signin { | ||||||
|  |             max-width: 330px; | ||||||
|  |             padding: 1rem; | ||||||
|  |           } | ||||||
|  |  | ||||||
|  |           .form-signin .form-floating:focus-within { | ||||||
|  |             z-index: 2; | ||||||
|  |           } | ||||||
|  |           .brand-icon { | ||||||
|  |             max-width: 100%; | ||||||
|  |           } | ||||||
|  |         </style> | ||||||
|  |     </head> | ||||||
|  |     <body class="d-flex align-items-center py-4 bg-body-tertiary"> | ||||||
|  |       <div class="card m-auto"> | ||||||
|  |         <main class="form-signin w-100 m-auto" id="flow-sfe-container"> | ||||||
|  |         </main> | ||||||
|  |         <span class="mt-3 mb-0 text-muted text-center">{% trans 'Powered by authentik' %}</span> | ||||||
|  |       </div> | ||||||
|  |       <script src="{% static 'dist/sfe/index.js' %}"></script> | ||||||
|  |     </body> | ||||||
|  | </html> | ||||||
| @ -1,6 +1,7 @@ | |||||||
| {% extends "base/skeleton.html" %} | {% extends "base/skeleton.html" %} | ||||||
| 
 | 
 | ||||||
| {% load static %} | {% load static %} | ||||||
|  | {% load authentik_core %} | ||||||
| 
 | 
 | ||||||
| {% block head_before %} | {% block head_before %} | ||||||
| {{ block.super }} | {{ block.super }} | ||||||
| @ -17,7 +18,7 @@ window.authentik.flow = { | |||||||
| {% endblock %} | {% endblock %} | ||||||
| 
 | 
 | ||||||
| {% block head %} | {% block head %} | ||||||
| <script src="{% static 'dist/flow/FlowInterface.js' %}?version={{ version }}" type="module"></script> | {% versioned_script "dist/flow/FlowInterface-%v.js" %} | ||||||
| <style> | <style> | ||||||
| :root { | :root { | ||||||
|     --ak-flow-background: url("{{ flow.background_url }}"); |     --ak-flow-background: url("{{ flow.background_url }}"); | ||||||
							
								
								
									
										41
									
								
								authentik/flows/views/interface.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										41
									
								
								authentik/flows/views/interface.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,41 @@ | |||||||
|  | """Interface views""" | ||||||
|  |  | ||||||
|  | from typing import Any | ||||||
|  |  | ||||||
|  | from django.shortcuts import get_object_or_404 | ||||||
|  | from ua_parser.user_agent_parser import Parse | ||||||
|  |  | ||||||
|  | from authentik.core.views.interface import InterfaceView | ||||||
|  | from authentik.flows.models import Flow | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class FlowInterfaceView(InterfaceView): | ||||||
|  |     """Flow interface""" | ||||||
|  |  | ||||||
|  |     def get_context_data(self, **kwargs: Any) -> dict[str, Any]: | ||||||
|  |         kwargs["flow"] = get_object_or_404(Flow, slug=self.kwargs.get("flow_slug")) | ||||||
|  |         kwargs["inspector"] = "inspector" in self.request.GET | ||||||
|  |         return super().get_context_data(**kwargs) | ||||||
|  |  | ||||||
|  |     def compat_needs_sfe(self) -> bool: | ||||||
|  |         """Check if we need to use the simplified flow executor for compatibility""" | ||||||
|  |         ua = Parse(self.request.META.get("HTTP_USER_AGENT", "")) | ||||||
|  |         if ua["user_agent"]["family"] == "IE": | ||||||
|  |             return True | ||||||
|  |         # Only use SFE for Edge 18 and older, after Edge 18 MS switched to chromium which supports | ||||||
|  |         # the default flow executor | ||||||
|  |         if ( | ||||||
|  |             ua["user_agent"]["family"] == "Edge" | ||||||
|  |             and int(ua["user_agent"]["major"]) <= 18  # noqa: PLR2004 | ||||||
|  |         ):  # noqa: PLR2004 | ||||||
|  |             return True | ||||||
|  |         # https://github.com/AzureAD/microsoft-authentication-library-for-objc | ||||||
|  |         # Used by Microsoft Teams/Office on macOS, and also uses a very outdated browser engine | ||||||
|  |         if "PKeyAuth" in ua["string"]: | ||||||
|  |             return True | ||||||
|  |         return False | ||||||
|  |  | ||||||
|  |     def get_template_names(self) -> list[str]: | ||||||
|  |         if self.compat_needs_sfe() or "sfe" in self.request.GET: | ||||||
|  |             return ["if/flow-sfe.html"] | ||||||
|  |         return ["if/flow.html"] | ||||||
| @ -50,7 +50,6 @@ cache: | |||||||
|   timeout: 300 |   timeout: 300 | ||||||
|   timeout_flows: 300 |   timeout_flows: 300 | ||||||
|   timeout_policies: 300 |   timeout_policies: 300 | ||||||
|   timeout_reputation: 300 |  | ||||||
|  |  | ||||||
| # channel: | # channel: | ||||||
| #   url: "" | #   url: "" | ||||||
| @ -116,6 +115,9 @@ events: | |||||||
|   context_processors: |   context_processors: | ||||||
|     geoip: "/geoip/GeoLite2-City.mmdb" |     geoip: "/geoip/GeoLite2-City.mmdb" | ||||||
|     asn: "/geoip/GeoLite2-ASN.mmdb" |     asn: "/geoip/GeoLite2-ASN.mmdb" | ||||||
|  | compliance: | ||||||
|  |   fips: | ||||||
|  |     enabled: false | ||||||
|  |  | ||||||
| cert_discovery_dir: /certs | cert_discovery_dir: /certs | ||||||
|  |  | ||||||
|  | |||||||
| @ -19,6 +19,7 @@ from structlog.stdlib import get_logger | |||||||
|  |  | ||||||
| from authentik.core.models import User | from authentik.core.models import User | ||||||
| from authentik.events.models import Event | from authentik.events.models import Event | ||||||
|  | from authentik.lib.expression.exceptions import ControlFlowException | ||||||
| from authentik.lib.utils.http import get_http_session | from authentik.lib.utils.http import get_http_session | ||||||
| from authentik.policies.models import Policy, PolicyBinding | from authentik.policies.models import Policy, PolicyBinding | ||||||
| from authentik.policies.process import PolicyProcess | from authentik.policies.process import PolicyProcess | ||||||
| @ -216,7 +217,8 @@ class BaseEvaluator: | |||||||
|                 # so the user only sees information relevant to them |                 # so the user only sees information relevant to them | ||||||
|                 # and none of our surrounding error handling |                 # and none of our surrounding error handling | ||||||
|                 exc.__traceback__ = exc.__traceback__.tb_next |                 exc.__traceback__ = exc.__traceback__.tb_next | ||||||
|                 self.handle_error(exc, expression_source) |                 if not isinstance(exc, ControlFlowException): | ||||||
|  |                     self.handle_error(exc, expression_source) | ||||||
|                 raise exc |                 raise exc | ||||||
|             return result |             return result | ||||||
|  |  | ||||||
|  | |||||||
							
								
								
									
										6
									
								
								authentik/lib/expression/exceptions.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										6
									
								
								authentik/lib/expression/exceptions.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,6 @@ | |||||||
|  | from authentik.lib.sentry import SentryIgnoredException | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class ControlFlowException(SentryIgnoredException): | ||||||
|  |     """Exceptions used to control the flow from exceptions, not reported as a warning/ | ||||||
|  |     error in logs""" | ||||||
| @ -4,8 +4,11 @@ from django.db.models import QuerySet | |||||||
| from django.http import HttpRequest | from django.http import HttpRequest | ||||||
|  |  | ||||||
| from authentik.core.expression.evaluator import PropertyMappingEvaluator | from authentik.core.expression.evaluator import PropertyMappingEvaluator | ||||||
| from authentik.core.expression.exceptions import PropertyMappingExpressionException | from authentik.core.expression.exceptions import ( | ||||||
|  |     PropertyMappingExpressionException, | ||||||
|  | ) | ||||||
| from authentik.core.models import PropertyMapping, User | from authentik.core.models import PropertyMapping, User | ||||||
|  | from authentik.lib.expression.exceptions import ControlFlowException | ||||||
|  |  | ||||||
|  |  | ||||||
| class PropertyMappingManager: | class PropertyMappingManager: | ||||||
| @ -57,7 +60,7 @@ class PropertyMappingManager: | |||||||
|             mapping.set_context(user, request, **kwargs) |             mapping.set_context(user, request, **kwargs) | ||||||
|             try: |             try: | ||||||
|                 value = mapping.evaluate(mapping.model.expression) |                 value = mapping.evaluate(mapping.model.expression) | ||||||
|             except PropertyMappingExpressionException as exc: |             except (PropertyMappingExpressionException, ControlFlowException) as exc: | ||||||
|                 raise exc from exc |                 raise exc from exc | ||||||
|             except Exception as exc: |             except Exception as exc: | ||||||
|                 raise PropertyMappingExpressionException(exc, mapping.model) from exc |                 raise PropertyMappingExpressionException(exc, mapping.model) from exc | ||||||
|  | |||||||
| @ -8,7 +8,7 @@ from rest_framework.fields import BooleanField | |||||||
| from rest_framework.request import Request | from rest_framework.request import Request | ||||||
| from rest_framework.response import Response | from rest_framework.response import Response | ||||||
|  |  | ||||||
| from authentik.core.api.utils import PassiveSerializer | from authentik.core.api.utils import ModelSerializer, PassiveSerializer | ||||||
| from authentik.events.api.tasks import SystemTaskSerializer | from authentik.events.api.tasks import SystemTaskSerializer | ||||||
| from authentik.lib.sync.outgoing.models import OutgoingSyncProvider | from authentik.lib.sync.outgoing.models import OutgoingSyncProvider | ||||||
|  |  | ||||||
| @ -54,3 +54,17 @@ class OutgoingSyncProviderStatusMixin: | |||||||
|                 "is_running": not lock_acquired, |                 "is_running": not lock_acquired, | ||||||
|             } |             } | ||||||
|         return Response(SyncStatusSerializer(status).data) |         return Response(SyncStatusSerializer(status).data) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class OutgoingSyncConnectionCreateMixin: | ||||||
|  |     """Mixin for connection objects that fetches remote data upon creation""" | ||||||
|  |  | ||||||
|  |     def perform_create(self, serializer: ModelSerializer): | ||||||
|  |         super().perform_create(serializer) | ||||||
|  |         try: | ||||||
|  |             instance = serializer.instance | ||||||
|  |             client = instance.provider.client_for_model(instance.__class__) | ||||||
|  |             client.update_single_attribute(instance) | ||||||
|  |             instance.save() | ||||||
|  |         except NotImplementedError: | ||||||
|  |             pass | ||||||
|  | |||||||
| @ -9,9 +9,9 @@ from structlog.stdlib import get_logger | |||||||
|  |  | ||||||
| from authentik.core.expression.exceptions import ( | from authentik.core.expression.exceptions import ( | ||||||
|     PropertyMappingExpressionException, |     PropertyMappingExpressionException, | ||||||
|     SkipObjectException, |  | ||||||
| ) | ) | ||||||
| from authentik.events.models import Event, EventAction | from authentik.events.models import Event, EventAction | ||||||
|  | from authentik.lib.expression.exceptions import ControlFlowException | ||||||
| from authentik.lib.sync.mapper import PropertyMappingManager | from authentik.lib.sync.mapper import PropertyMappingManager | ||||||
| from authentik.lib.sync.outgoing.exceptions import NotFoundSyncException, StopSync | from authentik.lib.sync.outgoing.exceptions import NotFoundSyncException, StopSync | ||||||
| from authentik.lib.utils.errors import exception_to_string | from authentik.lib.utils.errors import exception_to_string | ||||||
| @ -92,7 +92,7 @@ class BaseOutgoingSyncClient[ | |||||||
|             eval_kwargs.setdefault("user", None) |             eval_kwargs.setdefault("user", None) | ||||||
|             for value in self.mapper.iter_eval(**eval_kwargs): |             for value in self.mapper.iter_eval(**eval_kwargs): | ||||||
|                 always_merger.merge(raw_final_object, value) |                 always_merger.merge(raw_final_object, value) | ||||||
|         except SkipObjectException as exc: |         except ControlFlowException as exc: | ||||||
|             raise exc from exc |             raise exc from exc | ||||||
|         except PropertyMappingExpressionException as exc: |         except PropertyMappingExpressionException as exc: | ||||||
|             # Value error can be raised when assigning invalid data to an attribute |             # Value error can be raised when assigning invalid data to an attribute | ||||||
| @ -114,3 +114,8 @@ class BaseOutgoingSyncClient[ | |||||||
|         pre-link any users/groups in the remote system with the respective |         pre-link any users/groups in the remote system with the respective | ||||||
|         object in authentik based on a common identifier""" |         object in authentik based on a common identifier""" | ||||||
|         raise NotImplementedError() |         raise NotImplementedError() | ||||||
|  |  | ||||||
|  |     def update_single_attribute(self, connection: TConnection): | ||||||
|  |         """Update connection attributes on a connection object, when the connection | ||||||
|  |         is manually created""" | ||||||
|  |         raise NotImplementedError | ||||||
|  | |||||||
| @ -2,6 +2,7 @@ from collections.abc import Callable | |||||||
|  |  | ||||||
| from django.core.paginator import Paginator | from django.core.paginator import Paginator | ||||||
| from django.db.models import Model | from django.db.models import Model | ||||||
|  | from django.db.models.query import Q | ||||||
| from django.db.models.signals import m2m_changed, post_save, pre_delete | from django.db.models.signals import m2m_changed, post_save, pre_delete | ||||||
|  |  | ||||||
| from authentik.core.models import Group, User | from authentik.core.models import Group, User | ||||||
| @ -34,7 +35,9 @@ def register_signals( | |||||||
|  |  | ||||||
|     def model_post_save(sender: type[Model], instance: User | Group, created: bool, **_): |     def model_post_save(sender: type[Model], instance: User | Group, created: bool, **_): | ||||||
|         """Post save handler""" |         """Post save handler""" | ||||||
|         if not provider_type.objects.filter(backchannel_application__isnull=False).exists(): |         if not provider_type.objects.filter( | ||||||
|  |             Q(backchannel_application__isnull=False) | Q(application__isnull=False) | ||||||
|  |         ).exists(): | ||||||
|             return |             return | ||||||
|         task_sync_direct.delay(class_to_path(instance.__class__), instance.pk, Direction.add.value) |         task_sync_direct.delay(class_to_path(instance.__class__), instance.pk, Direction.add.value) | ||||||
|  |  | ||||||
| @ -43,7 +46,9 @@ def register_signals( | |||||||
|  |  | ||||||
|     def model_pre_delete(sender: type[Model], instance: User | Group, **_): |     def model_pre_delete(sender: type[Model], instance: User | Group, **_): | ||||||
|         """Pre-delete handler""" |         """Pre-delete handler""" | ||||||
|         if not provider_type.objects.filter(backchannel_application__isnull=False).exists(): |         if not provider_type.objects.filter( | ||||||
|  |             Q(backchannel_application__isnull=False) | Q(application__isnull=False) | ||||||
|  |         ).exists(): | ||||||
|             return |             return | ||||||
|         task_sync_direct.delay( |         task_sync_direct.delay( | ||||||
|             class_to_path(instance.__class__), instance.pk, Direction.remove.value |             class_to_path(instance.__class__), instance.pk, Direction.remove.value | ||||||
| @ -58,7 +63,9 @@ def register_signals( | |||||||
|         """Sync group membership""" |         """Sync group membership""" | ||||||
|         if action not in ["post_add", "post_remove"]: |         if action not in ["post_add", "post_remove"]: | ||||||
|             return |             return | ||||||
|         if not provider_type.objects.filter(backchannel_application__isnull=False).exists(): |         if not provider_type.objects.filter( | ||||||
|  |             Q(backchannel_application__isnull=False) | Q(application__isnull=False) | ||||||
|  |         ).exists(): | ||||||
|             return |             return | ||||||
|         # reverse: instance is a Group, pk_set is a list of user pks |         # reverse: instance is a Group, pk_set is a list of user pks | ||||||
|         # non-reverse: instance is a User, pk_set is a list of groups |         # non-reverse: instance is a User, pk_set is a list of groups | ||||||
|  | |||||||
| @ -5,6 +5,7 @@ from celery.exceptions import Retry | |||||||
| from celery.result import allow_join_result | from celery.result import allow_join_result | ||||||
| from django.core.paginator import Paginator | from django.core.paginator import Paginator | ||||||
| from django.db.models import Model, QuerySet | from django.db.models import Model, QuerySet | ||||||
|  | from django.db.models.query import Q | ||||||
| from django.utils.text import slugify | from django.utils.text import slugify | ||||||
| from django.utils.translation import gettext_lazy as _ | from django.utils.translation import gettext_lazy as _ | ||||||
| from structlog.stdlib import BoundLogger, get_logger | from structlog.stdlib import BoundLogger, get_logger | ||||||
| @ -37,7 +38,9 @@ class SyncTasks: | |||||||
|         self._provider_model = provider_model |         self._provider_model = provider_model | ||||||
|  |  | ||||||
|     def sync_all(self, single_sync: Callable[[int], None]): |     def sync_all(self, single_sync: Callable[[int], None]): | ||||||
|         for provider in self._provider_model.objects.filter(backchannel_application__isnull=False): |         for provider in self._provider_model.objects.filter( | ||||||
|  |             Q(backchannel_application__isnull=False) | Q(application__isnull=False) | ||||||
|  |         ): | ||||||
|             self.trigger_single_task(provider, single_sync) |             self.trigger_single_task(provider, single_sync) | ||||||
|  |  | ||||||
|     def trigger_single_task(self, provider: OutgoingSyncProvider, sync_task: Callable[[int], None]): |     def trigger_single_task(self, provider: OutgoingSyncProvider, sync_task: Callable[[int], None]): | ||||||
| @ -62,7 +65,8 @@ class SyncTasks: | |||||||
|             provider_pk=provider_pk, |             provider_pk=provider_pk, | ||||||
|         ) |         ) | ||||||
|         provider = self._provider_model.objects.filter( |         provider = self._provider_model.objects.filter( | ||||||
|             pk=provider_pk, backchannel_application__isnull=False |             Q(backchannel_application__isnull=False) | Q(application__isnull=False), | ||||||
|  |             pk=provider_pk, | ||||||
|         ).first() |         ).first() | ||||||
|         if not provider: |         if not provider: | ||||||
|             return |             return | ||||||
| @ -204,7 +208,9 @@ class SyncTasks: | |||||||
|         if not instance: |         if not instance: | ||||||
|             return |             return | ||||||
|         operation = Direction(raw_op) |         operation = Direction(raw_op) | ||||||
|         for provider in self._provider_model.objects.filter(backchannel_application__isnull=False): |         for provider in self._provider_model.objects.filter( | ||||||
|  |             Q(backchannel_application__isnull=False) | Q(application__isnull=False) | ||||||
|  |         ): | ||||||
|             client = provider.client_for_model(instance.__class__) |             client = provider.client_for_model(instance.__class__) | ||||||
|             # Check if the object is allowed within the provider's restrictions |             # Check if the object is allowed within the provider's restrictions | ||||||
|             queryset = provider.get_object_qs(instance.__class__) |             queryset = provider.get_object_qs(instance.__class__) | ||||||
| @ -223,6 +229,8 @@ class SyncTasks: | |||||||
|                     client.delete(instance) |                     client.delete(instance) | ||||||
|             except TransientSyncException as exc: |             except TransientSyncException as exc: | ||||||
|                 raise Retry() from exc |                 raise Retry() from exc | ||||||
|  |             except SkipObjectException: | ||||||
|  |                 continue | ||||||
|             except StopSync as exc: |             except StopSync as exc: | ||||||
|                 self.logger.warning(exc, provider_pk=provider.pk) |                 self.logger.warning(exc, provider_pk=provider.pk) | ||||||
|  |  | ||||||
| @ -233,7 +241,9 @@ class SyncTasks: | |||||||
|         group = Group.objects.filter(pk=group_pk).first() |         group = Group.objects.filter(pk=group_pk).first() | ||||||
|         if not group: |         if not group: | ||||||
|             return |             return | ||||||
|         for provider in self._provider_model.objects.filter(backchannel_application__isnull=False): |         for provider in self._provider_model.objects.filter( | ||||||
|  |             Q(backchannel_application__isnull=False) | Q(application__isnull=False) | ||||||
|  |         ): | ||||||
|             # Check if the object is allowed within the provider's restrictions |             # Check if the object is allowed within the provider's restrictions | ||||||
|             queryset: QuerySet = provider.get_object_qs(Group) |             queryset: QuerySet = provider.get_object_qs(Group) | ||||||
|             # The queryset we get from the provider must include the instance we've got given |             # The queryset we get from the provider must include the instance we've got given | ||||||
| @ -251,5 +261,7 @@ class SyncTasks: | |||||||
|                 client.update_group(group, operation, pk_set) |                 client.update_group(group, operation, pk_set) | ||||||
|             except TransientSyncException as exc: |             except TransientSyncException as exc: | ||||||
|                 raise Retry() from exc |                 raise Retry() from exc | ||||||
|  |             except SkipObjectException: | ||||||
|  |                 continue | ||||||
|             except StopSync as exc: |             except StopSync as exc: | ||||||
|                 self.logger.warning(exc, provider_pk=provider.pk) |                 self.logger.warning(exc, provider_pk=provider.pk) | ||||||
|  | |||||||
| @ -6,19 +6,21 @@ from django_filters.filters import ModelMultipleChoiceFilter | |||||||
| from django_filters.filterset import FilterSet | from django_filters.filterset import FilterSet | ||||||
| from drf_spectacular.utils import extend_schema | from drf_spectacular.utils import extend_schema | ||||||
| from rest_framework.decorators import action | from rest_framework.decorators import action | ||||||
| from rest_framework.fields import BooleanField, CharField, DateTimeField | from rest_framework.exceptions import ValidationError | ||||||
|  | from rest_framework.fields import BooleanField, CharField, DateTimeField, SerializerMethodField | ||||||
| from rest_framework.relations import PrimaryKeyRelatedField | from rest_framework.relations import PrimaryKeyRelatedField | ||||||
| from rest_framework.request import Request | from rest_framework.request import Request | ||||||
| from rest_framework.response import Response | from rest_framework.response import Response | ||||||
| from rest_framework.serializers import ModelSerializer, ValidationError |  | ||||||
| from rest_framework.viewsets import ModelViewSet | from rest_framework.viewsets import ModelViewSet | ||||||
|  |  | ||||||
| from authentik import get_build_hash | from authentik import get_build_hash | ||||||
| from authentik.core.api.providers import ProviderSerializer | from authentik.core.api.providers import ProviderSerializer | ||||||
| from authentik.core.api.used_by import UsedByMixin | from authentik.core.api.used_by import UsedByMixin | ||||||
| from authentik.core.api.utils import JSONDictField, PassiveSerializer | from authentik.core.api.utils import JSONDictField, ModelSerializer, PassiveSerializer | ||||||
| from authentik.core.models import Provider | from authentik.core.models import Provider | ||||||
|  | from authentik.enterprise.license import LicenseKey | ||||||
| from authentik.enterprise.providers.rac.models import RACProvider | from authentik.enterprise.providers.rac.models import RACProvider | ||||||
|  | from authentik.lib.utils.time import timedelta_from_string, timedelta_string_validator | ||||||
| from authentik.outposts.api.service_connections import ServiceConnectionSerializer | from authentik.outposts.api.service_connections import ServiceConnectionSerializer | ||||||
| from authentik.outposts.apps import MANAGED_OUTPOST, MANAGED_OUTPOST_NAME | from authentik.outposts.apps import MANAGED_OUTPOST, MANAGED_OUTPOST_NAME | ||||||
| from authentik.outposts.models import ( | from authentik.outposts.models import ( | ||||||
| @ -48,6 +50,10 @@ class OutpostSerializer(ModelSerializer): | |||||||
|     service_connection_obj = ServiceConnectionSerializer( |     service_connection_obj = ServiceConnectionSerializer( | ||||||
|         source="service_connection", read_only=True |         source="service_connection", read_only=True | ||||||
|     ) |     ) | ||||||
|  |     refresh_interval_s = SerializerMethodField() | ||||||
|  |  | ||||||
|  |     def get_refresh_interval_s(self, obj: Outpost) -> int: | ||||||
|  |         return int(timedelta_from_string(obj.config.refresh_interval).total_seconds()) | ||||||
|  |  | ||||||
|     def validate_name(self, name: str) -> str: |     def validate_name(self, name: str) -> str: | ||||||
|         """Validate name (especially for embedded outpost)""" |         """Validate name (especially for embedded outpost)""" | ||||||
| @ -83,7 +89,8 @@ class OutpostSerializer(ModelSerializer): | |||||||
|     def validate_config(self, config) -> dict: |     def validate_config(self, config) -> dict: | ||||||
|         """Check that the config has all required fields""" |         """Check that the config has all required fields""" | ||||||
|         try: |         try: | ||||||
|             from_dict(OutpostConfig, config) |             parsed = from_dict(OutpostConfig, config) | ||||||
|  |             timedelta_string_validator(parsed.refresh_interval) | ||||||
|         except DaciteError as exc: |         except DaciteError as exc: | ||||||
|             raise ValidationError(f"Failed to validate config: {str(exc)}") from exc |             raise ValidationError(f"Failed to validate config: {str(exc)}") from exc | ||||||
|         return config |         return config | ||||||
| @ -98,6 +105,7 @@ class OutpostSerializer(ModelSerializer): | |||||||
|             "providers_obj", |             "providers_obj", | ||||||
|             "service_connection", |             "service_connection", | ||||||
|             "service_connection_obj", |             "service_connection_obj", | ||||||
|  |             "refresh_interval_s", | ||||||
|             "token_identifier", |             "token_identifier", | ||||||
|             "config", |             "config", | ||||||
|             "managed", |             "managed", | ||||||
| @ -120,7 +128,7 @@ class OutpostHealthSerializer(PassiveSerializer): | |||||||
|     golang_version = CharField(read_only=True) |     golang_version = CharField(read_only=True) | ||||||
|     openssl_enabled = BooleanField(read_only=True) |     openssl_enabled = BooleanField(read_only=True) | ||||||
|     openssl_version = CharField(read_only=True) |     openssl_version = CharField(read_only=True) | ||||||
|     fips_enabled = BooleanField(read_only=True) |     fips_enabled = SerializerMethodField() | ||||||
|  |  | ||||||
|     version_should = CharField(read_only=True) |     version_should = CharField(read_only=True) | ||||||
|     version_outdated = BooleanField(read_only=True) |     version_outdated = BooleanField(read_only=True) | ||||||
| @ -130,6 +138,12 @@ class OutpostHealthSerializer(PassiveSerializer): | |||||||
|  |  | ||||||
|     hostname = CharField(read_only=True, required=False) |     hostname = CharField(read_only=True, required=False) | ||||||
|  |  | ||||||
|  |     def get_fips_enabled(self, obj: dict) -> bool | None: | ||||||
|  |         """Get FIPS enabled""" | ||||||
|  |         if not LicenseKey.get_total().is_valid(): | ||||||
|  |             return None | ||||||
|  |         return obj["fips_enabled"] | ||||||
|  |  | ||||||
|  |  | ||||||
| class OutpostFilter(FilterSet): | class OutpostFilter(FilterSet): | ||||||
|     """Filter for Outposts""" |     """Filter for Outposts""" | ||||||
|  | |||||||
| @ -12,13 +12,13 @@ from rest_framework.decorators import action | |||||||
| from rest_framework.fields import BooleanField, CharField, ReadOnlyField | from rest_framework.fields import BooleanField, CharField, ReadOnlyField | ||||||
| from rest_framework.request import Request | from rest_framework.request import Request | ||||||
| from rest_framework.response import Response | from rest_framework.response import Response | ||||||
| from rest_framework.serializers import ModelSerializer |  | ||||||
| from rest_framework.viewsets import GenericViewSet, ModelViewSet | from rest_framework.viewsets import GenericViewSet, ModelViewSet | ||||||
|  |  | ||||||
| from authentik.core.api.object_types import TypesMixin | from authentik.core.api.object_types import TypesMixin | ||||||
| from authentik.core.api.used_by import UsedByMixin | from authentik.core.api.used_by import UsedByMixin | ||||||
| from authentik.core.api.utils import ( | from authentik.core.api.utils import ( | ||||||
|     MetaNameSerializer, |     MetaNameSerializer, | ||||||
|  |     ModelSerializer, | ||||||
|     PassiveSerializer, |     PassiveSerializer, | ||||||
| ) | ) | ||||||
| from authentik.outposts.models import ( | from authentik.outposts.models import ( | ||||||
|  | |||||||
| @ -13,16 +13,17 @@ import authentik.outposts.models | |||||||
|  |  | ||||||
|  |  | ||||||
| def fix_missing_token_identifier(apps: Apps, schema_editor: BaseDatabaseSchemaEditor): | def fix_missing_token_identifier(apps: Apps, schema_editor: BaseDatabaseSchemaEditor): | ||||||
|  |     db_alias = schema_editor.connection.alias | ||||||
|     User = apps.get_model("authentik_core", "User") |     User = apps.get_model("authentik_core", "User") | ||||||
|     Token = apps.get_model("authentik_core", "Token") |     Token = apps.get_model("authentik_core", "Token") | ||||||
|     from authentik.outposts.models import Outpost |     from authentik.outposts.models import Outpost | ||||||
|  |  | ||||||
|     for outpost in Outpost.objects.using(schema_editor.connection.alias).all().only("pk"): |     for outpost in Outpost.objects.using(db_alias).all().only("pk"): | ||||||
|         user_identifier = outpost.user_identifier |         user_identifier = outpost.user_identifier | ||||||
|         users = User.objects.filter(username=user_identifier) |         users = User.objects.using(db_alias).filter(username=user_identifier) | ||||||
|         if not users.exists(): |         if not users.exists(): | ||||||
|             continue |             continue | ||||||
|         tokens = Token.objects.filter(user=users.first()) |         tokens = Token.objects.using(db_alias).filter(user=users.first()) | ||||||
|         for token in tokens: |         for token in tokens: | ||||||
|             if token.identifier != outpost.token_identifier: |             if token.identifier != outpost.token_identifier: | ||||||
|                 token.identifier = outpost.token_identifier |                 token.identifier = outpost.token_identifier | ||||||
| @ -37,8 +38,8 @@ def migrate_to_service_connection(apps: Apps, schema_editor: BaseDatabaseSchemaE | |||||||
|         "authentik_outposts", "KubernetesServiceConnection" |         "authentik_outposts", "KubernetesServiceConnection" | ||||||
|     ) |     ) | ||||||
|  |  | ||||||
|     docker = DockerServiceConnection.objects.filter(local=True).first() |     docker = DockerServiceConnection.objects.using(db_alias).filter(local=True).first() | ||||||
|     k8s = KubernetesServiceConnection.objects.filter(local=True).first() |     k8s = KubernetesServiceConnection.objects.using(db_alias).filter(local=True).first() | ||||||
|  |  | ||||||
|     try: |     try: | ||||||
|         for outpost in Outpost.objects.using(db_alias).all().exclude(deployment_type="custom"): |         for outpost in Outpost.objects.using(db_alias).all().exclude(deployment_type="custom"): | ||||||
| @ -54,21 +55,21 @@ def migrate_to_service_connection(apps: Apps, schema_editor: BaseDatabaseSchemaE | |||||||
|  |  | ||||||
|  |  | ||||||
| def remove_pb_prefix_users(apps: Apps, schema_editor: BaseDatabaseSchemaEditor): | def remove_pb_prefix_users(apps: Apps, schema_editor: BaseDatabaseSchemaEditor): | ||||||
|     alias = schema_editor.connection.alias |     db_alias = schema_editor.connection.alias | ||||||
|     User = apps.get_model("authentik_core", "User") |     User = apps.get_model("authentik_core", "User") | ||||||
|     Outpost = apps.get_model("authentik_outposts", "Outpost") |     Outpost = apps.get_model("authentik_outposts", "Outpost") | ||||||
|  |  | ||||||
|     for outpost in Outpost.objects.using(alias).all(): |     for outpost in Outpost.objects.using(db_alias).all(): | ||||||
|         matching = User.objects.using(alias).filter(username=f"pb-outpost-{outpost.uuid.hex}") |         matching = User.objects.using(db_alias).filter(username=f"pb-outpost-{outpost.uuid.hex}") | ||||||
|         if matching.exists(): |         if matching.exists(): | ||||||
|             matching.delete() |             matching.delete() | ||||||
|  |  | ||||||
|  |  | ||||||
| def update_config_prefix(apps: Apps, schema_editor: BaseDatabaseSchemaEditor): | def update_config_prefix(apps: Apps, schema_editor: BaseDatabaseSchemaEditor): | ||||||
|     alias = schema_editor.connection.alias |     db_alias = schema_editor.connection.alias | ||||||
|     Outpost = apps.get_model("authentik_outposts", "Outpost") |     Outpost = apps.get_model("authentik_outposts", "Outpost") | ||||||
|  |  | ||||||
|     for outpost in Outpost.objects.using(alias).all(): |     for outpost in Outpost.objects.using(db_alias).all(): | ||||||
|         config = outpost._config |         config = outpost._config | ||||||
|         for key in list(config): |         for key in list(config): | ||||||
|             if "passbook" in key: |             if "passbook" in key: | ||||||
|  | |||||||
| @ -61,6 +61,7 @@ class OutpostConfig: | |||||||
|  |  | ||||||
|     log_level: str = CONFIG.get("log_level") |     log_level: str = CONFIG.get("log_level") | ||||||
|     object_naming_template: str = field(default="ak-outpost-%(name)s") |     object_naming_template: str = field(default="ak-outpost-%(name)s") | ||||||
|  |     refresh_interval: str = "minutes=5" | ||||||
|  |  | ||||||
|     container_image: str | None = field(default=None) |     container_image: str | None = field(default=None) | ||||||
|  |  | ||||||
|  | |||||||
| @ -2,7 +2,6 @@ | |||||||
|  |  | ||||||
| from dataclasses import asdict | from dataclasses import asdict | ||||||
|  |  | ||||||
| from channels.exceptions import DenyConnection |  | ||||||
| from channels.routing import URLRouter | from channels.routing import URLRouter | ||||||
| from channels.testing import WebsocketCommunicator | from channels.testing import WebsocketCommunicator | ||||||
| from django.test import TransactionTestCase | from django.test import TransactionTestCase | ||||||
| @ -37,9 +36,8 @@ class TestOutpostWS(TransactionTestCase): | |||||||
|         communicator = WebsocketCommunicator( |         communicator = WebsocketCommunicator( | ||||||
|             URLRouter(websocket.websocket_urlpatterns), f"/ws/outpost/{self.outpost.pk}/" |             URLRouter(websocket.websocket_urlpatterns), f"/ws/outpost/{self.outpost.pk}/" | ||||||
|         ) |         ) | ||||||
|         with self.assertRaises(DenyConnection): |         connected, _ = await communicator.connect() | ||||||
|             connected, _ = await communicator.connect() |         self.assertFalse(connected) | ||||||
|             self.assertFalse(connected) |  | ||||||
|  |  | ||||||
|     async def test_auth_valid(self): |     async def test_auth_valid(self): | ||||||
|         """Test auth with token""" |         """Test auth with token""" | ||||||
|  | |||||||
| @ -2,13 +2,13 @@ | |||||||
|  |  | ||||||
| from django.urls import path | from django.urls import path | ||||||
|  |  | ||||||
| from authentik.core.channels import TokenOutpostMiddleware |  | ||||||
| from authentik.outposts.api.outposts import OutpostViewSet | from authentik.outposts.api.outposts import OutpostViewSet | ||||||
| from authentik.outposts.api.service_connections import ( | from authentik.outposts.api.service_connections import ( | ||||||
|     DockerServiceConnectionViewSet, |     DockerServiceConnectionViewSet, | ||||||
|     KubernetesServiceConnectionViewSet, |     KubernetesServiceConnectionViewSet, | ||||||
|     ServiceConnectionViewSet, |     ServiceConnectionViewSet, | ||||||
| ) | ) | ||||||
|  | from authentik.outposts.channels import TokenOutpostMiddleware | ||||||
| from authentik.outposts.consumer import OutpostConsumer | from authentik.outposts.consumer import OutpostConsumer | ||||||
| from authentik.root.middleware import ChannelsLoggingMiddleware | from authentik.root.middleware import ChannelsLoggingMiddleware | ||||||
|  |  | ||||||
|  | |||||||
| @ -5,13 +5,15 @@ from collections import OrderedDict | |||||||
| from django.core.exceptions import ObjectDoesNotExist | from django.core.exceptions import ObjectDoesNotExist | ||||||
| from django_filters.filters import BooleanFilter, ModelMultipleChoiceFilter | from django_filters.filters import BooleanFilter, ModelMultipleChoiceFilter | ||||||
| from django_filters.filterset import FilterSet | from django_filters.filterset import FilterSet | ||||||
| from rest_framework.serializers import ModelSerializer, PrimaryKeyRelatedField, ValidationError | from rest_framework.exceptions import ValidationError | ||||||
|  | from rest_framework.serializers import PrimaryKeyRelatedField | ||||||
| from rest_framework.viewsets import ModelViewSet | from rest_framework.viewsets import ModelViewSet | ||||||
| from structlog.stdlib import get_logger | from structlog.stdlib import get_logger | ||||||
|  |  | ||||||
| from authentik.core.api.groups import GroupSerializer | from authentik.core.api.groups import GroupSerializer | ||||||
| from authentik.core.api.used_by import UsedByMixin | from authentik.core.api.used_by import UsedByMixin | ||||||
| from authentik.core.api.users import UserSerializer | from authentik.core.api.users import UserSerializer | ||||||
|  | from authentik.core.api.utils import ModelSerializer | ||||||
| from authentik.policies.api.policies import PolicySerializer | from authentik.policies.api.policies import PolicySerializer | ||||||
| from authentik.policies.models import PolicyBinding, PolicyBindingModel | from authentik.policies.models import PolicyBinding, PolicyBindingModel | ||||||
|  |  | ||||||
|  | |||||||
| @ -6,9 +6,9 @@ from drf_spectacular.utils import OpenApiResponse, extend_schema | |||||||
| from guardian.shortcuts import get_objects_for_user | from guardian.shortcuts import get_objects_for_user | ||||||
| from rest_framework import mixins | from rest_framework import mixins | ||||||
| from rest_framework.decorators import action | from rest_framework.decorators import action | ||||||
|  | from rest_framework.fields import SerializerMethodField | ||||||
| from rest_framework.request import Request | from rest_framework.request import Request | ||||||
| from rest_framework.response import Response | from rest_framework.response import Response | ||||||
| from rest_framework.serializers import ModelSerializer, SerializerMethodField |  | ||||||
| from rest_framework.viewsets import GenericViewSet | from rest_framework.viewsets import GenericViewSet | ||||||
| from structlog.stdlib import get_logger | from structlog.stdlib import get_logger | ||||||
|  |  | ||||||
| @ -18,6 +18,7 @@ from authentik.core.api.used_by import UsedByMixin | |||||||
| from authentik.core.api.utils import ( | from authentik.core.api.utils import ( | ||||||
|     CacheSerializer, |     CacheSerializer, | ||||||
|     MetaNameSerializer, |     MetaNameSerializer, | ||||||
|  |     ModelSerializer, | ||||||
| ) | ) | ||||||
| from authentik.events.logs import LogEventSerializer, capture_logs | from authentik.events.logs import LogEventSerializer, capture_logs | ||||||
| from authentik.policies.api.exec import PolicyTestResultSerializer, PolicyTestSerializer | from authentik.policies.api.exec import PolicyTestResultSerializer, PolicyTestSerializer | ||||||
|  | |||||||
| @ -1,16 +1,22 @@ | |||||||
| """Reputation policy API Views""" | """Reputation policy API Views""" | ||||||
|  |  | ||||||
| from django.utils.translation import gettext_lazy as _ | from django.utils.translation import gettext_lazy as _ | ||||||
|  | from django_filters.filters import BaseInFilter, CharFilter | ||||||
|  | from django_filters.filterset import FilterSet | ||||||
| from rest_framework import mixins | from rest_framework import mixins | ||||||
| from rest_framework.exceptions import ValidationError | from rest_framework.exceptions import ValidationError | ||||||
| from rest_framework.serializers import ModelSerializer |  | ||||||
| from rest_framework.viewsets import GenericViewSet, ModelViewSet | from rest_framework.viewsets import GenericViewSet, ModelViewSet | ||||||
|  |  | ||||||
| from authentik.core.api.used_by import UsedByMixin | from authentik.core.api.used_by import UsedByMixin | ||||||
|  | from authentik.core.api.utils import ModelSerializer | ||||||
| from authentik.policies.api.policies import PolicySerializer | from authentik.policies.api.policies import PolicySerializer | ||||||
| from authentik.policies.reputation.models import Reputation, ReputationPolicy | from authentik.policies.reputation.models import Reputation, ReputationPolicy | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class CharInFilter(BaseInFilter, CharFilter): | ||||||
|  |     pass | ||||||
|  |  | ||||||
|  |  | ||||||
| class ReputationPolicySerializer(PolicySerializer): | class ReputationPolicySerializer(PolicySerializer): | ||||||
|     """Reputation Policy Serializer""" |     """Reputation Policy Serializer""" | ||||||
|  |  | ||||||
| @ -38,6 +44,16 @@ class ReputationPolicyViewSet(UsedByMixin, ModelViewSet): | |||||||
|     ordering = ["name"] |     ordering = ["name"] | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class ReputationFilter(FilterSet): | ||||||
|  |     """Filter for reputation""" | ||||||
|  |  | ||||||
|  |     identifier_in = CharInFilter(field_name="identifier", lookup_expr="in") | ||||||
|  |  | ||||||
|  |     class Meta: | ||||||
|  |         model = Reputation | ||||||
|  |         fields = ["identifier", "ip", "score"] | ||||||
|  |  | ||||||
|  |  | ||||||
| class ReputationSerializer(ModelSerializer): | class ReputationSerializer(ModelSerializer): | ||||||
|     """Reputation Serializer""" |     """Reputation Serializer""" | ||||||
|  |  | ||||||
| @ -66,5 +82,5 @@ class ReputationViewSet( | |||||||
|     queryset = Reputation.objects.all() |     queryset = Reputation.objects.all() | ||||||
|     serializer_class = ReputationSerializer |     serializer_class = ReputationSerializer | ||||||
|     search_fields = ["identifier", "ip", "score"] |     search_fields = ["identifier", "ip", "score"] | ||||||
|     filterset_fields = ["identifier", "ip", "score"] |     filterset_class = ReputationFilter | ||||||
|     ordering = ["ip"] |     ordering = ["ip"] | ||||||
|  | |||||||
| @ -2,8 +2,6 @@ | |||||||
|  |  | ||||||
| from authentik.blueprints.apps import ManagedAppConfig | from authentik.blueprints.apps import ManagedAppConfig | ||||||
|  |  | ||||||
| CACHE_KEY_PREFIX = "goauthentik.io/policies/reputation/scores/" |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class AuthentikPolicyReputationConfig(ManagedAppConfig): | class AuthentikPolicyReputationConfig(ManagedAppConfig): | ||||||
|     """Authentik reputation app config""" |     """Authentik reputation app config""" | ||||||
|  | |||||||
| @ -0,0 +1,25 @@ | |||||||
|  | # Generated by Django 5.0.6 on 2024-06-11 08:50 | ||||||
|  |  | ||||||
|  | from django.db import migrations, models | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class Migration(migrations.Migration): | ||||||
|  |  | ||||||
|  |     dependencies = [ | ||||||
|  |         ("authentik_policies_reputation", "0006_reputation_ip_asn_data"), | ||||||
|  |     ] | ||||||
|  |  | ||||||
|  |     operations = [ | ||||||
|  |         migrations.AddIndex( | ||||||
|  |             model_name="reputation", | ||||||
|  |             index=models.Index(fields=["identifier"], name="authentik_p_identif_9434d7_idx"), | ||||||
|  |         ), | ||||||
|  |         migrations.AddIndex( | ||||||
|  |             model_name="reputation", | ||||||
|  |             index=models.Index(fields=["ip"], name="authentik_p_ip_7ad0df_idx"), | ||||||
|  |         ), | ||||||
|  |         migrations.AddIndex( | ||||||
|  |             model_name="reputation", | ||||||
|  |             index=models.Index(fields=["ip", "identifier"], name="authentik_p_ip_d779aa_idx"), | ||||||
|  |         ), | ||||||
|  |     ] | ||||||
| @ -96,3 +96,8 @@ class Reputation(ExpiringModel, SerializerModel): | |||||||
|         verbose_name = _("Reputation Score") |         verbose_name = _("Reputation Score") | ||||||
|         verbose_name_plural = _("Reputation Scores") |         verbose_name_plural = _("Reputation Scores") | ||||||
|         unique_together = ("identifier", "ip") |         unique_together = ("identifier", "ip") | ||||||
|  |         indexes = [ | ||||||
|  |             models.Index(fields=["identifier"]), | ||||||
|  |             models.Index(fields=["ip"]), | ||||||
|  |             models.Index(fields=["ip", "identifier"]), | ||||||
|  |         ] | ||||||
|  | |||||||
| @ -1,11 +0,0 @@ | |||||||
| """Reputation Settings""" |  | ||||||
|  |  | ||||||
| from celery.schedules import crontab |  | ||||||
|  |  | ||||||
| CELERY_BEAT_SCHEDULE = { |  | ||||||
|     "policies_reputation_save": { |  | ||||||
|         "task": "authentik.policies.reputation.tasks.save_reputation", |  | ||||||
|         "schedule": crontab(minute="1-59/5"), |  | ||||||
|         "options": {"queue": "authentik_scheduled"}, |  | ||||||
|     }, |  | ||||||
| } |  | ||||||
| @ -1,40 +1,42 @@ | |||||||
| """authentik reputation request signals""" | """authentik reputation request signals""" | ||||||
|  |  | ||||||
| from django.contrib.auth.signals import user_logged_in | from django.contrib.auth.signals import user_logged_in | ||||||
| from django.core.cache import cache | from django.db import transaction | ||||||
|  | from django.db.models import F | ||||||
| from django.dispatch import receiver | from django.dispatch import receiver | ||||||
| from django.http import HttpRequest | from django.http import HttpRequest | ||||||
| from structlog.stdlib import get_logger | from structlog.stdlib import get_logger | ||||||
|  |  | ||||||
| from authentik.core.signals import login_failed | from authentik.core.signals import login_failed | ||||||
| from authentik.lib.config import CONFIG | from authentik.events.context_processors.asn import ASN_CONTEXT_PROCESSOR | ||||||
| from authentik.policies.reputation.apps import CACHE_KEY_PREFIX | from authentik.events.context_processors.geoip import GEOIP_CONTEXT_PROCESSOR | ||||||
| from authentik.policies.reputation.tasks import save_reputation | from authentik.policies.reputation.models import Reputation, reputation_expiry | ||||||
| from authentik.root.middleware import ClientIPMiddleware | from authentik.root.middleware import ClientIPMiddleware | ||||||
| from authentik.stages.identification.signals import identification_failed | from authentik.stages.identification.signals import identification_failed | ||||||
|  |  | ||||||
| LOGGER = get_logger() | LOGGER = get_logger() | ||||||
| CACHE_TIMEOUT = CONFIG.get_int("cache.timeout_reputation") |  | ||||||
|  |  | ||||||
|  |  | ||||||
| def update_score(request: HttpRequest, identifier: str, amount: int): | def update_score(request: HttpRequest, identifier: str, amount: int): | ||||||
|     """Update score for IP and User""" |     """Update score for IP and User""" | ||||||
|     remote_ip = ClientIPMiddleware.get_client_ip(request) |     remote_ip = ClientIPMiddleware.get_client_ip(request) | ||||||
|  |  | ||||||
|     try: |     with transaction.atomic(): | ||||||
|         # We only update the cache here, as its faster than writing to the DB |         reputation, created = Reputation.objects.select_for_update().get_or_create( | ||||||
|         score = cache.get_or_set( |             ip=remote_ip, | ||||||
|             CACHE_KEY_PREFIX + remote_ip + "/" + identifier, |             identifier=identifier, | ||||||
|             {"ip": remote_ip, "identifier": identifier, "score": 0}, |             defaults={ | ||||||
|             CACHE_TIMEOUT, |                 "score": amount, | ||||||
|  |                 "ip_geo_data": GEOIP_CONTEXT_PROCESSOR.city_dict(remote_ip) or {}, | ||||||
|  |                 "ip_asn_data": ASN_CONTEXT_PROCESSOR.asn_dict(remote_ip) or {}, | ||||||
|  |                 "expires": reputation_expiry(), | ||||||
|  |             }, | ||||||
|         ) |         ) | ||||||
|         score["score"] += amount |  | ||||||
|         cache.set(CACHE_KEY_PREFIX + remote_ip + "/" + identifier, score) |  | ||||||
|     except ValueError as exc: |  | ||||||
|         LOGGER.warning("failed to set reputation", exc=exc) |  | ||||||
|  |  | ||||||
|  |         if not created: | ||||||
|  |             reputation.score = F("score") + amount | ||||||
|  |             reputation.save() | ||||||
|     LOGGER.debug("Updated score", amount=amount, for_user=identifier, for_ip=remote_ip) |     LOGGER.debug("Updated score", amount=amount, for_user=identifier, for_ip=remote_ip) | ||||||
|     save_reputation.delay() |  | ||||||
|  |  | ||||||
|  |  | ||||||
| @receiver(login_failed) | @receiver(login_failed) | ||||||
|  | |||||||
| @ -1,32 +0,0 @@ | |||||||
| """Reputation tasks""" |  | ||||||
|  |  | ||||||
| from django.core.cache import cache |  | ||||||
| from structlog.stdlib import get_logger |  | ||||||
|  |  | ||||||
| from authentik.events.context_processors.asn import ASN_CONTEXT_PROCESSOR |  | ||||||
| from authentik.events.context_processors.geoip import GEOIP_CONTEXT_PROCESSOR |  | ||||||
| from authentik.events.models import TaskStatus |  | ||||||
| from authentik.events.system_tasks import SystemTask, prefill_task |  | ||||||
| from authentik.policies.reputation.apps import CACHE_KEY_PREFIX |  | ||||||
| from authentik.policies.reputation.models import Reputation |  | ||||||
| from authentik.root.celery import CELERY_APP |  | ||||||
|  |  | ||||||
| LOGGER = get_logger() |  | ||||||
|  |  | ||||||
|  |  | ||||||
| @CELERY_APP.task(bind=True, base=SystemTask) |  | ||||||
| @prefill_task |  | ||||||
| def save_reputation(self: SystemTask): |  | ||||||
|     """Save currently cached reputation to database""" |  | ||||||
|     objects_to_update = [] |  | ||||||
|     for _, score in cache.get_many(cache.keys(CACHE_KEY_PREFIX + "*")).items(): |  | ||||||
|         rep, _ = Reputation.objects.get_or_create( |  | ||||||
|             ip=score["ip"], |  | ||||||
|             identifier=score["identifier"], |  | ||||||
|         ) |  | ||||||
|         rep.ip_geo_data = GEOIP_CONTEXT_PROCESSOR.city_dict(score["ip"]) or {} |  | ||||||
|         rep.ip_asn_data = ASN_CONTEXT_PROCESSOR.asn_dict(score["ip"]) or {} |  | ||||||
|         rep.score = score["score"] |  | ||||||
|         objects_to_update.append(rep) |  | ||||||
|     Reputation.objects.bulk_update(objects_to_update, ["score", "ip_geo_data"]) |  | ||||||
|     self.set_status(TaskStatus.SUCCESSFUL, "Successfully updated Reputation") |  | ||||||
| @ -1,14 +1,11 @@ | |||||||
| """test reputation signals and policy""" | """test reputation signals and policy""" | ||||||
|  |  | ||||||
| from django.core.cache import cache |  | ||||||
| from django.test import RequestFactory, TestCase | from django.test import RequestFactory, TestCase | ||||||
|  |  | ||||||
| from authentik.core.models import User | from authentik.core.models import User | ||||||
| from authentik.lib.generators import generate_id | from authentik.lib.generators import generate_id | ||||||
| from authentik.policies.reputation.api import ReputationPolicySerializer | from authentik.policies.reputation.api import ReputationPolicySerializer | ||||||
| from authentik.policies.reputation.apps import CACHE_KEY_PREFIX |  | ||||||
| from authentik.policies.reputation.models import Reputation, ReputationPolicy | from authentik.policies.reputation.models import Reputation, ReputationPolicy | ||||||
| from authentik.policies.reputation.tasks import save_reputation |  | ||||||
| from authentik.policies.types import PolicyRequest | from authentik.policies.types import PolicyRequest | ||||||
| from authentik.stages.password import BACKEND_INBUILT | from authentik.stages.password import BACKEND_INBUILT | ||||||
| from authentik.stages.password.stage import authenticate | from authentik.stages.password.stage import authenticate | ||||||
| @ -22,8 +19,6 @@ class TestReputationPolicy(TestCase): | |||||||
|         self.request = self.request_factory.get("/") |         self.request = self.request_factory.get("/") | ||||||
|         self.test_ip = "127.0.0.1" |         self.test_ip = "127.0.0.1" | ||||||
|         self.test_username = "test" |         self.test_username = "test" | ||||||
|         keys = cache.keys(CACHE_KEY_PREFIX + "*") |  | ||||||
|         cache.delete_many(keys) |  | ||||||
|         # We need a user for the one-to-one in userreputation |         # We need a user for the one-to-one in userreputation | ||||||
|         self.user = User.objects.create(username=self.test_username) |         self.user = User.objects.create(username=self.test_username) | ||||||
|         self.backends = [BACKEND_INBUILT] |         self.backends = [BACKEND_INBUILT] | ||||||
| @ -34,13 +29,6 @@ class TestReputationPolicy(TestCase): | |||||||
|         authenticate( |         authenticate( | ||||||
|             self.request, self.backends, username=self.test_username, password=self.test_username |             self.request, self.backends, username=self.test_username, password=self.test_username | ||||||
|         ) |         ) | ||||||
|         # Test value in cache |  | ||||||
|         self.assertEqual( |  | ||||||
|             cache.get(CACHE_KEY_PREFIX + self.test_ip + "/" + self.test_username), |  | ||||||
|             {"ip": "127.0.0.1", "identifier": "test", "score": -1}, |  | ||||||
|         ) |  | ||||||
|         # Save cache and check db values |  | ||||||
|         save_reputation.delay().get() |  | ||||||
|         self.assertEqual(Reputation.objects.get(ip=self.test_ip).score, -1) |         self.assertEqual(Reputation.objects.get(ip=self.test_ip).score, -1) | ||||||
|  |  | ||||||
|     def test_user_reputation(self): |     def test_user_reputation(self): | ||||||
| @ -49,15 +37,17 @@ class TestReputationPolicy(TestCase): | |||||||
|         authenticate( |         authenticate( | ||||||
|             self.request, self.backends, username=self.test_username, password=self.test_username |             self.request, self.backends, username=self.test_username, password=self.test_username | ||||||
|         ) |         ) | ||||||
|         # Test value in cache |  | ||||||
|         self.assertEqual( |  | ||||||
|             cache.get(CACHE_KEY_PREFIX + self.test_ip + "/" + self.test_username), |  | ||||||
|             {"ip": "127.0.0.1", "identifier": "test", "score": -1}, |  | ||||||
|         ) |  | ||||||
|         # Save cache and check db values |  | ||||||
|         save_reputation.delay().get() |  | ||||||
|         self.assertEqual(Reputation.objects.get(identifier=self.test_username).score, -1) |         self.assertEqual(Reputation.objects.get(identifier=self.test_username).score, -1) | ||||||
|  |  | ||||||
|  |     def test_update_reputation(self): | ||||||
|  |         """test reputation update""" | ||||||
|  |         Reputation.objects.create(identifier=self.test_username, ip=self.test_ip, score=43) | ||||||
|  |         # Trigger negative reputation | ||||||
|  |         authenticate( | ||||||
|  |             self.request, self.backends, username=self.test_username, password=self.test_username | ||||||
|  |         ) | ||||||
|  |         self.assertEqual(Reputation.objects.get(identifier=self.test_username).score, 42) | ||||||
|  |  | ||||||
|     def test_policy(self): |     def test_policy(self): | ||||||
|         """Test Policy""" |         """Test Policy""" | ||||||
|         request = PolicyRequest(user=self.user) |         request = PolicyRequest(user=self.user) | ||||||
|  | |||||||
| @ -5,11 +5,11 @@ from django.db.models.query import Q | |||||||
| from django_filters.filters import BooleanFilter | from django_filters.filters import BooleanFilter | ||||||
| from django_filters.filterset import FilterSet | from django_filters.filterset import FilterSet | ||||||
| from rest_framework.fields import CharField, ListField, SerializerMethodField | from rest_framework.fields import CharField, ListField, SerializerMethodField | ||||||
| from rest_framework.serializers import ModelSerializer |  | ||||||
| from rest_framework.viewsets import ModelViewSet, ReadOnlyModelViewSet | from rest_framework.viewsets import ModelViewSet, ReadOnlyModelViewSet | ||||||
|  |  | ||||||
| from authentik.core.api.providers import ProviderSerializer | from authentik.core.api.providers import ProviderSerializer | ||||||
| from authentik.core.api.used_by import UsedByMixin | from authentik.core.api.used_by import UsedByMixin | ||||||
|  | from authentik.core.api.utils import ModelSerializer | ||||||
| from authentik.providers.ldap.models import LDAPProvider | from authentik.providers.ldap.models import LDAPProvider | ||||||
|  |  | ||||||
|  |  | ||||||
|  | |||||||
| @ -7,12 +7,11 @@ from guardian.utils import get_anonymous_user | |||||||
| from rest_framework import mixins | from rest_framework import mixins | ||||||
| from rest_framework.fields import CharField, ListField, SerializerMethodField | from rest_framework.fields import CharField, ListField, SerializerMethodField | ||||||
| from rest_framework.filters import OrderingFilter, SearchFilter | from rest_framework.filters import OrderingFilter, SearchFilter | ||||||
| from rest_framework.serializers import ModelSerializer |  | ||||||
| from rest_framework.viewsets import GenericViewSet | from rest_framework.viewsets import GenericViewSet | ||||||
|  |  | ||||||
| from authentik.core.api.used_by import UsedByMixin | from authentik.core.api.used_by import UsedByMixin | ||||||
| from authentik.core.api.users import UserSerializer | from authentik.core.api.users import UserSerializer | ||||||
| from authentik.core.api.utils import MetaNameSerializer | from authentik.core.api.utils import MetaNameSerializer, ModelSerializer | ||||||
| from authentik.providers.oauth2.api.providers import OAuth2ProviderSerializer | from authentik.providers.oauth2.api.providers import OAuth2ProviderSerializer | ||||||
| from authentik.providers.oauth2.models import AccessToken, AuthorizationCode, RefreshToken | from authentik.providers.oauth2.models import AccessToken, AuthorizationCode, RefreshToken | ||||||
|  |  | ||||||
|  | |||||||
| @ -4,9 +4,10 @@ from urllib.parse import urlencode | |||||||
|  |  | ||||||
| from django.urls import reverse | from django.urls import reverse | ||||||
|  |  | ||||||
| from authentik.core.models import Application | from authentik.core.models import Application, Group | ||||||
| from authentik.core.tests.utils import create_test_admin_user, create_test_brand, create_test_flow | from authentik.core.tests.utils import create_test_admin_user, create_test_brand, create_test_flow | ||||||
| from authentik.lib.generators import generate_id | from authentik.lib.generators import generate_id | ||||||
|  | from authentik.policies.models import PolicyBinding | ||||||
| from authentik.providers.oauth2.models import DeviceToken, OAuth2Provider | from authentik.providers.oauth2.models import DeviceToken, OAuth2Provider | ||||||
| from authentik.providers.oauth2.tests.utils import OAuthTestCase | from authentik.providers.oauth2.tests.utils import OAuthTestCase | ||||||
| from authentik.providers.oauth2.views.device_init import QS_KEY_CODE | from authentik.providers.oauth2.views.device_init import QS_KEY_CODE | ||||||
| @ -77,3 +78,23 @@ class TesOAuth2DeviceInit(OAuthTestCase): | |||||||
|             + "?" |             + "?" | ||||||
|             + urlencode({QS_KEY_CODE: token.user_code}), |             + urlencode({QS_KEY_CODE: token.user_code}), | ||||||
|         ) |         ) | ||||||
|  |  | ||||||
|  |     def test_device_init_denied(self): | ||||||
|  |         """Test device init""" | ||||||
|  |         group = Group.objects.create(name="foo") | ||||||
|  |         PolicyBinding.objects.create( | ||||||
|  |             group=group, | ||||||
|  |             target=self.application, | ||||||
|  |             order=0, | ||||||
|  |         ) | ||||||
|  |         token = DeviceToken.objects.create( | ||||||
|  |             user_code="foo", | ||||||
|  |             provider=self.provider, | ||||||
|  |         ) | ||||||
|  |         res = self.client.get( | ||||||
|  |             reverse("authentik_providers_oauth2_root:device-login") | ||||||
|  |             + "?" | ||||||
|  |             + urlencode({QS_KEY_CODE: token.user_code}) | ||||||
|  |         ) | ||||||
|  |         self.assertEqual(res.status_code, 200) | ||||||
|  |         self.assertIn(b"Permission denied", res.content) | ||||||
|  | |||||||
| @ -11,10 +11,11 @@ from django.views.decorators.csrf import csrf_exempt | |||||||
| from rest_framework.throttling import AnonRateThrottle | from rest_framework.throttling import AnonRateThrottle | ||||||
| from structlog.stdlib import get_logger | from structlog.stdlib import get_logger | ||||||
|  |  | ||||||
|  | from authentik.core.models import Application | ||||||
| from authentik.lib.config import CONFIG | from authentik.lib.config import CONFIG | ||||||
| from authentik.lib.utils.time import timedelta_from_string | from authentik.lib.utils.time import timedelta_from_string | ||||||
| from authentik.providers.oauth2.models import DeviceToken, OAuth2Provider | from authentik.providers.oauth2.models import DeviceToken, OAuth2Provider | ||||||
| from authentik.providers.oauth2.views.device_init import QS_KEY_CODE, get_application | from authentik.providers.oauth2.views.device_init import QS_KEY_CODE | ||||||
|  |  | ||||||
| LOGGER = get_logger() | LOGGER = get_logger() | ||||||
|  |  | ||||||
| @ -37,7 +38,9 @@ class DeviceView(View): | |||||||
|         ).first() |         ).first() | ||||||
|         if not provider: |         if not provider: | ||||||
|             return HttpResponseBadRequest() |             return HttpResponseBadRequest() | ||||||
|         if not get_application(provider): |         try: | ||||||
|  |             _ = provider.application | ||||||
|  |         except Application.DoesNotExist: | ||||||
|             return HttpResponseBadRequest() |             return HttpResponseBadRequest() | ||||||
|         self.provider = provider |         self.provider = provider | ||||||
|         self.client_id = client_id |         self.client_id = client_id | ||||||
|  | |||||||
| @ -1,8 +1,9 @@ | |||||||
| """Device flow views""" | """Device flow views""" | ||||||
|  |  | ||||||
|  | from typing import Any | ||||||
|  |  | ||||||
| from django.http import HttpRequest, HttpResponse | from django.http import HttpRequest, HttpResponse | ||||||
| from django.utils.translation import gettext as _ | from django.utils.translation import gettext as _ | ||||||
| from django.views import View |  | ||||||
| from rest_framework.exceptions import ValidationError | from rest_framework.exceptions import ValidationError | ||||||
| from rest_framework.fields import CharField, IntegerField | from rest_framework.fields import CharField, IntegerField | ||||||
| from structlog.stdlib import get_logger | from structlog.stdlib import get_logger | ||||||
| @ -16,7 +17,8 @@ from authentik.flows.planner import PLAN_CONTEXT_APPLICATION, PLAN_CONTEXT_SSO, | |||||||
| from authentik.flows.stage import ChallengeStageView | from authentik.flows.stage import ChallengeStageView | ||||||
| from authentik.flows.views.executor import SESSION_KEY_PLAN | from authentik.flows.views.executor import SESSION_KEY_PLAN | ||||||
| from authentik.lib.utils.urls import redirect_with_qs | from authentik.lib.utils.urls import redirect_with_qs | ||||||
| from authentik.providers.oauth2.models import DeviceToken, OAuth2Provider | from authentik.policies.views import PolicyAccessView | ||||||
|  | from authentik.providers.oauth2.models import DeviceToken | ||||||
| from authentik.providers.oauth2.views.device_finish import ( | from authentik.providers.oauth2.views.device_finish import ( | ||||||
|     PLAN_CONTEXT_DEVICE, |     PLAN_CONTEXT_DEVICE, | ||||||
|     OAuthDeviceCodeFinishStage, |     OAuthDeviceCodeFinishStage, | ||||||
| @ -31,60 +33,52 @@ LOGGER = get_logger() | |||||||
| QS_KEY_CODE = "code"  # nosec | QS_KEY_CODE = "code"  # nosec | ||||||
|  |  | ||||||
|  |  | ||||||
| def get_application(provider: OAuth2Provider) -> Application | None: | class CodeValidatorView(PolicyAccessView): | ||||||
|     """Get application from provider""" |     """Helper to validate frontside token""" | ||||||
|     try: |  | ||||||
|         app = provider.application |     def __init__(self, code: str, **kwargs: Any) -> None: | ||||||
|         if not app: |         super().__init__(**kwargs) | ||||||
|  |         self.code = code | ||||||
|  |  | ||||||
|  |     def resolve_provider_application(self): | ||||||
|  |         self.token = DeviceToken.objects.filter(user_code=self.code).first() | ||||||
|  |         if not self.token: | ||||||
|  |             raise Application.DoesNotExist | ||||||
|  |         self.provider = self.token.provider | ||||||
|  |         self.application = self.token.provider.application | ||||||
|  |  | ||||||
|  |     def get(self, request: HttpRequest, *args, **kwargs): | ||||||
|  |         scope_descriptions = UserInfoView().get_scope_descriptions(self.token.scope, self.provider) | ||||||
|  |         planner = FlowPlanner(self.provider.authorization_flow) | ||||||
|  |         planner.allow_empty_flows = True | ||||||
|  |         planner.use_cache = False | ||||||
|  |         try: | ||||||
|  |             plan = planner.plan( | ||||||
|  |                 request, | ||||||
|  |                 { | ||||||
|  |                     PLAN_CONTEXT_SSO: True, | ||||||
|  |                     PLAN_CONTEXT_APPLICATION: self.application, | ||||||
|  |                     # OAuth2 related params | ||||||
|  |                     PLAN_CONTEXT_DEVICE: self.token, | ||||||
|  |                     # 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: | ||||||
|  |             LOGGER.warning("Flow not applicable to user") | ||||||
|             return None |             return None | ||||||
|         return app |         plan.insert_stage(in_memory_stage(OAuthDeviceCodeFinishStage)) | ||||||
|     except Application.DoesNotExist: |         request.session[SESSION_KEY_PLAN] = plan | ||||||
|         return None |         return redirect_with_qs( | ||||||
|  |             "authentik_core:if-flow", | ||||||
|  |             request.GET, | ||||||
| def validate_code(code: int, request: HttpRequest) -> HttpResponse | None: |             flow_slug=self.token.provider.authorization_flow.slug, | ||||||
|     """Validate user token""" |  | ||||||
|     token = DeviceToken.objects.filter( |  | ||||||
|         user_code=code, |  | ||||||
|     ).first() |  | ||||||
|     if not token: |  | ||||||
|         return None |  | ||||||
|  |  | ||||||
|     app = get_application(token.provider) |  | ||||||
|     if not app: |  | ||||||
|         return None |  | ||||||
|  |  | ||||||
|     scope_descriptions = UserInfoView().get_scope_descriptions(token.scope, token.provider) |  | ||||||
|     planner = FlowPlanner(token.provider.authorization_flow) |  | ||||||
|     planner.allow_empty_flows = True |  | ||||||
|     planner.use_cache = False |  | ||||||
|     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( |  | ||||||
|         "authentik_core:if-flow", |  | ||||||
|         request.GET, |  | ||||||
|         flow_slug=token.provider.authorization_flow.slug, |  | ||||||
|     ) |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class DeviceEntryView(View): | class DeviceEntryView(PolicyAccessView): | ||||||
|     """View used to initiate the device-code flow, url entered by endusers""" |     """View used to initiate the device-code flow, url entered by endusers""" | ||||||
|  |  | ||||||
|     def dispatch(self, request: HttpRequest) -> HttpResponse: |     def dispatch(self, request: HttpRequest) -> HttpResponse: | ||||||
| @ -94,7 +88,9 @@ class DeviceEntryView(View): | |||||||
|             LOGGER.info("Brand has no device code flow configured", brand=brand) |             LOGGER.info("Brand has no device code flow configured", brand=brand) | ||||||
|             return HttpResponse(status=404) |             return HttpResponse(status=404) | ||||||
|         if QS_KEY_CODE in request.GET: |         if QS_KEY_CODE in request.GET: | ||||||
|             validation = validate_code(request.GET[QS_KEY_CODE], request) |             validation = CodeValidatorView(request.GET[QS_KEY_CODE], request=request).dispatch( | ||||||
|  |                 request | ||||||
|  |             ) | ||||||
|             if validation: |             if validation: | ||||||
|                 return validation |                 return validation | ||||||
|             LOGGER.info("Got code from query parameter but no matching token found") |             LOGGER.info("Got code from query parameter but no matching token found") | ||||||
| @ -131,7 +127,7 @@ class OAuthDeviceCodeChallengeResponse(ChallengeResponse): | |||||||
|  |  | ||||||
|     def validate_code(self, code: int) -> HttpResponse | None: |     def validate_code(self, code: int) -> HttpResponse | None: | ||||||
|         """Validate code and save the returned http response""" |         """Validate code and save the returned http response""" | ||||||
|         response = validate_code(code, self.stage.request) |         response = CodeValidatorView(code, request=self.stage.request).dispatch(self.stage.request) | ||||||
|         if not response: |         if not response: | ||||||
|             raise ValidationError(_("Invalid code"), "invalid") |             raise ValidationError(_("Invalid code"), "invalid") | ||||||
|         return response |         return response | ||||||
|  | |||||||
| @ -6,12 +6,11 @@ from django.utils.translation import gettext_lazy as _ | |||||||
| from drf_spectacular.utils import extend_schema_field | from drf_spectacular.utils import extend_schema_field | ||||||
| from rest_framework.exceptions import ValidationError | from rest_framework.exceptions import ValidationError | ||||||
| from rest_framework.fields import CharField, ListField, ReadOnlyField, SerializerMethodField | from rest_framework.fields import CharField, ListField, ReadOnlyField, SerializerMethodField | ||||||
| from rest_framework.serializers import ModelSerializer |  | ||||||
| from rest_framework.viewsets import ModelViewSet, ReadOnlyModelViewSet | from rest_framework.viewsets import ModelViewSet, ReadOnlyModelViewSet | ||||||
|  |  | ||||||
| from authentik.core.api.providers import ProviderSerializer | from authentik.core.api.providers import ProviderSerializer | ||||||
| from authentik.core.api.used_by import UsedByMixin | from authentik.core.api.used_by import UsedByMixin | ||||||
| from authentik.core.api.utils import PassiveSerializer | from authentik.core.api.utils import ModelSerializer, PassiveSerializer | ||||||
| from authentik.lib.utils.time import timedelta_from_string | from authentik.lib.utils.time import timedelta_from_string | ||||||
| from authentik.providers.oauth2.models import ScopeMapping | from authentik.providers.oauth2.models import ScopeMapping | ||||||
| from authentik.providers.oauth2.views.provider import ProviderInfoView | from authentik.providers.oauth2.views.provider import ProviderInfoView | ||||||
|  | |||||||
| @ -1,11 +1,11 @@ | |||||||
| """RadiusProvider API Views""" | """RadiusProvider API Views""" | ||||||
|  |  | ||||||
| from rest_framework.fields import CharField, ListField | from rest_framework.fields import CharField, ListField | ||||||
| from rest_framework.serializers import ModelSerializer |  | ||||||
| from rest_framework.viewsets import ModelViewSet, ReadOnlyModelViewSet | from rest_framework.viewsets import ModelViewSet, ReadOnlyModelViewSet | ||||||
|  |  | ||||||
| from authentik.core.api.providers import ProviderSerializer | from authentik.core.api.providers import ProviderSerializer | ||||||
| from authentik.core.api.used_by import UsedByMixin | from authentik.core.api.used_by import UsedByMixin | ||||||
|  | from authentik.core.api.utils import ModelSerializer | ||||||
| from authentik.providers.radius.models import RadiusProvider | from authentik.providers.radius.models import RadiusProvider | ||||||
|  |  | ||||||
|  |  | ||||||
|  | |||||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user
	