Compare commits

..

3 Commits

Author SHA1 Message Date
435ba598bb add tests
Signed-off-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
2024-09-25 15:07:00 +02:00
582511abcc add version
Signed-off-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
2024-09-25 14:42:24 +02:00
80ea1dae81 analytics: init
Signed-off-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
2024-09-25 14:37:27 +02:00
608 changed files with 33706 additions and 15690 deletions

View File

@ -1,5 +1,5 @@
[bumpversion] [bumpversion]
current_version = 2024.8.3 current_version = 2024.8.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*))?

View File

@ -23,6 +23,7 @@ updates:
- package-ecosystem: npm - package-ecosystem: npm
directories: directories:
- "/web" - "/web"
- "/tests/wdio"
- "/web/sfe" - "/web/sfe"
schedule: schedule:
interval: daily interval: daily

View File

@ -1,7 +1,7 @@
<!-- <!--
👋 Hi there! Welcome. 👋 Hi there! Welcome.
Please check the Contributing guidelines: https://docs.goauthentik.io/docs/developer-docs/#how-can-i-contribute Please check the Contributing guidelines: https://goauthentik.io/developer-docs/#how-can-i-contribute
--> -->
## Details ## Details

View File

@ -24,11 +24,17 @@ jobs:
- prettier-check - prettier-check
project: project:
- web - web
- tests/wdio
include: include:
- command: tsc - command: tsc
project: web project: web
- command: lit-analyse - command: lit-analyse
project: web project: web
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
@ -44,7 +50,15 @@ jobs:
- name: Lint - name: Lint
working-directory: ${{ matrix.project }}/ working-directory: ${{ matrix.project }}/
run: npm run ${{ matrix.command }} run: npm run ${{ matrix.command }}
ci-web-mark:
needs:
- lint
runs-on: ubuntu-latest
steps:
- run: echo mark
build: build:
needs:
- ci-web-mark
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
@ -60,13 +74,6 @@ jobs:
- name: build - name: build
working-directory: web/ working-directory: web/
run: npm run build run: npm run build
ci-web-mark:
needs:
- build
- lint
runs-on: ubuntu-latest
steps:
- run: echo mark
test: test:
needs: needs:
- ci-web-mark - ci-web-mark

View File

@ -94,7 +94,7 @@ RUN --mount=type=secret,id=GEOIPUPDATE_ACCOUNT_ID \
/bin/sh -c "/usr/bin/entry.sh || echo 'Failed to get GeoIP database, disabling'; exit 0" /bin/sh -c "/usr/bin/entry.sh || echo 'Failed to get GeoIP database, disabling'; exit 0"
# Stage 5: Python dependencies # Stage 5: Python dependencies
FROM ghcr.io/goauthentik/fips-python:3.12.7-slim-bookworm-fips-full AS python-deps FROM ghcr.io/goauthentik/fips-python:3.12.6-slim-bookworm-fips-full AS python-deps
ARG TARGETARCH ARG TARGETARCH
ARG TARGETVARIANT ARG TARGETVARIANT
@ -124,7 +124,7 @@ RUN --mount=type=bind,target=./pyproject.toml,src=./pyproject.toml \
pip install --force-reinstall /wheels/*" pip install --force-reinstall /wheels/*"
# Stage 6: Run # Stage 6: Run
FROM ghcr.io/goauthentik/fips-python:3.12.7-slim-bookworm-fips-full AS final-image FROM ghcr.io/goauthentik/fips-python:3.12.6-slim-bookworm-fips-full AS final-image
ARG VERSION ARG VERSION
ARG GIT_BUILD_HASH ARG GIT_BUILD_HASH

View File

@ -19,13 +19,14 @@ pg_name := $(shell python -m authentik.lib.config postgresql.name 2>/dev/null)
CODESPELL_ARGS = -D - -D .github/codespell-dictionary.txt \ CODESPELL_ARGS = -D - -D .github/codespell-dictionary.txt \
-I .github/codespell-words.txt \ -I .github/codespell-words.txt \
-S 'web/src/locales/**' \ -S 'web/src/locales/**' \
-S 'website/docs/developer-docs/api/reference/**' \ -S 'website/developer-docs/api/reference/**' \
authentik \ authentik \
internal \ internal \
cmd \ cmd \
web/src \ web/src \
website/src \ website/src \
website/blog \ website/blog \
website/developer-docs \
website/docs \ website/docs \
website/integrations \ website/integrations \
website/src website/src

View File

@ -34,7 +34,7 @@ For bigger setups, there is a Helm Chart [here](https://github.com/goauthentik/h
## Development ## Development
See [Developer Documentation](https://docs.goauthentik.io/docs/developer-docs/?utm_source=github) See [Developer Documentation](https://goauthentik.io/developer-docs/?utm_source=github)
## Security ## Security

View File

@ -2,7 +2,7 @@
from os import environ from os import environ
__version__ = "2024.8.3" __version__ = "2024.8.2"
ENV_GIT_HASH_KEY = "GIT_BUILD_HASH" ENV_GIT_HASH_KEY = "GIT_BUILD_HASH"

View File

@ -0,0 +1,20 @@
"""authentik admin analytics"""
from typing import Any
from django.utils.translation import gettext_lazy as _
from authentik.root.celery import CELERY_APP
def get_analytics_description() -> dict[str, str]:
return {
"worker_count": _("Number of running workers"),
}
def get_analytics_data() -> dict[str, Any]:
worker_count = len(CELERY_APP.control.ping(timeout=0.5))
return {
"worker_count": worker_count,
}

View File

View File

@ -0,0 +1,54 @@
"""authentik analytics api"""
from drf_spectacular.utils import extend_schema, inline_serializer
from rest_framework.fields import CharField, DictField
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.viewsets import ViewSet
from authentik.analytics.utils import get_analytics_data, get_analytics_description
from authentik.core.api.utils import PassiveSerializer
from authentik.rbac.permissions import HasPermission
class AnalyticsDescriptionSerializer(PassiveSerializer):
label = CharField()
desc = CharField()
class AnalyticsDescriptionViewSet(ViewSet):
"""Read-only view of analytics descriptions"""
permission_classes = [HasPermission("authentik_rbac.view_system_settings")]
@extend_schema(responses={200: AnalyticsDescriptionSerializer})
def list(self, request: Request) -> Response:
"""Read-only view of analytics descriptions"""
data = []
for label, desc in get_analytics_description().items():
data.append({"label": label, "desc": desc})
return Response(AnalyticsDescriptionSerializer(data, many=True).data)
class AnalyticsDataViewSet(ViewSet):
"""Read-only view of analytics descriptions"""
permission_classes = [HasPermission("authentik_rbac.edit_system_settings")]
@extend_schema(
responses={
200: inline_serializer(
name="AnalyticsData",
fields={
"data": DictField(),
},
)
}
)
def list(self, request: Request) -> Response:
"""Read-only view of analytics descriptions"""
return Response(
{
"data": get_analytics_data(force=True),
}
)

View File

@ -0,0 +1,12 @@
"""authentik analytics app config"""
from authentik.blueprints.apps import ManagedAppConfig
class AuthentikAdminConfig(ManagedAppConfig):
"""authentik analytics app config"""
name = "authentik.analytics"
label = "authentik_analytics"
verbose_name = "authentik Analytics"
default = True

View File

@ -0,0 +1,19 @@
"""authentik analytics mixins"""
from typing import Any
from django.utils.translation import gettext_lazy as _
class AnalyticsMixin:
@classmethod
def get_analytics_description(cls) -> dict[str, str]:
object_name = _(cls._meta.verbose_name)
count_desc = _("Number of {object_name} objects".format_map({"object_name": object_name}))
return {
"count": count_desc,
}
@classmethod
def get_analytics_data(cls) -> dict[str, Any]:
return {"count": cls.objects.all().count()}

View File

@ -0,0 +1,17 @@
"""authentik admin settings"""
from celery.schedules import crontab
from authentik.lib.utils.time import fqdn_rand
CELERY_BEAT_SCHEDULE = {
"analytics_send": {
"task": "authentik.analytics.tasks.send_analytics",
"schedule": crontab(
minute=fqdn_rand("analytics_send"),
hour=fqdn_rand("analytics_send", stop=24),
day_of_week=fqdn_rand("analytics_send", 7),
),
"options": {"queue": "authentik_scheduled"},
}
}

View File

@ -0,0 +1,45 @@
"""authentik admin tasks"""
import orjson
from django.utils.translation import gettext_lazy as _
from requests import RequestException
from structlog.stdlib import get_logger
from authentik.analytics.utils import get_analytics_data
from authentik.events.models import Event, EventAction
from authentik.events.system_tasks import SystemTask, TaskStatus, prefill_task
from authentik.lib.utils.http import get_http_session
from authentik.root.celery import CELERY_APP
from authentik.tenants.models import Tenant
LOGGER = get_logger()
@CELERY_APP.task(bind=True, base=SystemTask)
@prefill_task
def send_analytics(self: SystemTask):
"""Send analytics"""
for tenant in Tenant.objects.filter(ready=True):
data = get_analytics_data(current_tenant=tenant)
if not tenant.analytics_enabled or not data:
self.set_status(TaskStatus.WARNING, "Analytics disabled. Nothing was sent.")
return
try:
response = get_http_session().post(
"https://customers.goauthentik.io/api/analytics/post/", json=data
)
response.raise_for_status()
self.set_status(
TaskStatus.SUCCESSFUL,
"Successfully sent analytics",
orjson.dumps(
data, option=orjson.OPT_INDENT_2 | orjson.OPT_NON_STR_KEYS | orjson.OPT_UTC_Z
).decode(),
)
Event.new(
EventAction.ANALYTICS_SENT,
message=_("Analytics sent"),
analytics_data=data,
).save()
except (RequestException, IndexError) as exc:
self.set_error(exc)

View File

@ -0,0 +1,76 @@
"""authentik analytics tests"""
from json import loads
from requests_mock import Mocker
from django.test import TestCase
from django.urls import reverse
from authentik import __version__
from authentik.analytics.tasks import send_analytics
from authentik.analytics.utils import get_analytics_apps_data, get_analytics_apps_description, get_analytics_data, get_analytics_description, get_analytics_models_data, get_analytics_models_description
from authentik.core.models import Group, User
from authentik.events.models import Event, EventAction
from authentik.lib.generators import generate_id
from authentik.tenants.utils import get_current_tenant
class TestAnalytics(TestCase):
"""test analytics api"""
def setUp(self) -> None:
super().setUp()
self.user = User.objects.create(username=generate_id())
self.group = Group.objects.create(name=generate_id(), is_superuser=True)
self.group.users.add(self.user)
self.client.force_login(self.user)
self.tenant = get_current_tenant()
def test_description_api(self):
"""Test Version API"""
response = self.client.get(reverse("authentik_api:analytics-description-list"))
self.assertEqual(response.status_code, 200)
loads(response.content)
def test_data_api(self):
"""Test Version API"""
response = self.client.get(reverse("authentik_api:analytics-data-list"))
self.assertEqual(response.status_code, 200)
body = loads(response.content)
self.assertEqual(body["data"]["version"], __version__)
def test_sending_enabled(self):
"""Test analytics sending"""
self.tenant.analytics_enabled = True
self.tenant.save()
with Mocker() as mocker:
mocker.post("https://customers.goauthentik.io/api/analytics/post/", status_code=200)
send_analytics.delay().get()
self.assertTrue(
Event.objects.filter(
action=EventAction.ANALYTICS_SENT
).exists()
)
def test_sending_disabled(self):
"""Test analytics sending"""
self.tenant.analytics_enabled = False
self.tenant.save()
send_analytics.delay().get()
self.assertFalse(
Event.objects.filter(
action=EventAction.ANALYTICS_SENT
).exists()
)
def test_description_data_match_apps(self):
"""Test description and data keys match"""
description = get_analytics_apps_description()
data = get_analytics_apps_data()
self.assertEqual(data.keys(), description.keys())
def test_description_data_match_models(self):
"""Test description and data keys match"""
description = get_analytics_models_description()
data = get_analytics_models_data()
self.assertEqual(data.keys(), description.keys())

View File

@ -0,0 +1,8 @@
"""API URLs"""
from authentik.analytics.api import AnalyticsDataViewSet, AnalyticsDescriptionViewSet
api_urlpatterns = [
("analytics/description", AnalyticsDescriptionViewSet, "analytics-description"),
("analytics/data", AnalyticsDataViewSet, "analytics-data"),
]

View File

@ -0,0 +1,112 @@
"""authentik analytics utils"""
from hashlib import sha256
from importlib import import_module
from typing import Any
from structlog import get_logger
from authentik import get_full_version
from authentik.analytics.models import AnalyticsMixin
from authentik.lib.utils.reflection import get_apps
from authentik.root.install_id import get_install_id
from authentik.tenants.models import Tenant
from authentik.tenants.utils import get_current_tenant
LOGGER = get_logger()
def get_analytics_apps() -> dict:
modules = {}
for _authentik_app in get_apps():
try:
module = import_module(f"{_authentik_app.name}.analytics")
except ModuleNotFoundError:
continue
except ImportError as exc:
LOGGER.warning(
"Could not import app's analytics", app_name=_authentik_app.name, exc=exc
)
continue
if not hasattr(module, "get_analytics_description") or not hasattr(
module, "get_analytics_data"
):
LOGGER.debug(
"App does not define API URLs",
app_name=_authentik_app.name,
)
continue
modules[_authentik_app.label] = module
return modules
def get_analytics_apps_description() -> dict[str, str]:
result = {}
for app_label, module in get_analytics_apps().items():
for k, v in module.get_analytics_description().items():
result[f"{app_label}/app/{k}"] = v
return result
def get_analytics_apps_data() -> dict[str, Any]:
result = {}
for app_label, module in get_analytics_apps().items():
for k, v in module.get_analytics_data().items():
result[f"{app_label}/app/{k}"] = v
return result
def get_analytics_models() -> list[AnalyticsMixin]:
def get_subclasses(cls):
for subclass in cls.__subclasses__():
if subclass.__subclasses__():
yield from get_subclasses(subclass)
elif not subclass._meta.abstract:
yield subclass
return list(get_subclasses(AnalyticsMixin))
def get_analytics_models_description() -> dict[str, str]:
result = {}
for model in get_analytics_models():
for k, v in model.get_analytics_description().items():
result[f"{model._meta.app_label}/models/{model._meta.object_name}/{k}"] = v
return result
def get_analytics_models_data() -> dict[str, Any]:
result = {}
for model in get_analytics_models():
for k, v in model.get_analytics_data().items():
result[f"{model._meta.app_label}/models/{model._meta.object_name}/{k}"] = v
return result
def get_analytics_description() -> dict[str, str]:
return {
**get_analytics_apps_description(),
**get_analytics_models_description(),
}
def get_analytics_data(current_tenant: Tenant | None = None, force: bool = False) -> dict[str, Any]:
current_tenant = current_tenant or get_current_tenant()
if not current_tenant.analytics_enabled and not force:
return {}
data = {
**get_analytics_apps_data(),
**get_analytics_models_data(),
}
to_remove = []
for key in data.keys():
if key not in current_tenant.analytics_sources:
to_remove.append(key)
for key in to_remove:
del data[key]
return {
**data,
"install_id_hash": sha256(get_install_id().encode()).hexdigest(),
"tenant_hash": sha256(current_tenant.tenant_uuid.bytes).hexdigest(),
"version": get_full_version(),
}

View File

@ -51,11 +51,9 @@ class BlueprintInstanceSerializer(ModelSerializer):
context = self.instance.context if self.instance else {} context = self.instance.context if self.instance else {}
valid, logs = Importer.from_string(content, context).validate() valid, logs = Importer.from_string(content, context).validate()
if not valid: if not valid:
text_logs = "\n".join([x["event"] for x in logs])
raise ValidationError( raise ValidationError(
[ _("Failed to validate blueprint: {logs}".format_map({"logs": text_logs}))
_("Failed to validate blueprint"),
*[f"- {x.event}" for x in logs],
]
) )
return content return content

View File

@ -29,7 +29,9 @@ def check_blueprint_v1_file(BlueprintInstance: type, db_alias, path: Path):
if version != 1: if version != 1:
return return
blueprint_file.seek(0) blueprint_file.seek(0)
instance = BlueprintInstance.objects.using(db_alias).filter(path=path).first() instance: BlueprintInstance = (
BlueprintInstance.objects.using(db_alias).filter(path=path).first()
)
rel_path = path.relative_to(Path(CONFIG.get("blueprints_dir"))) rel_path = path.relative_to(Path(CONFIG.get("blueprints_dir")))
meta = None meta = None
if metadata: if metadata:

View File

@ -78,5 +78,5 @@ class TestBlueprintsV1API(APITestCase):
self.assertEqual(res.status_code, 400) self.assertEqual(res.status_code, 400)
self.assertJSONEqual( self.assertJSONEqual(
res.content.decode(), res.content.decode(),
{"content": ["Failed to validate blueprint", "- Invalid blueprint version"]}, {"content": ["Failed to validate blueprint: Invalid blueprint version"]},
) )

View File

@ -69,7 +69,7 @@ from authentik.stages.authenticator_webauthn.models import WebAuthnDeviceType
from authentik.tenants.models import Tenant from authentik.tenants.models import Tenant
# Context set when the serializer is created in a blueprint context # Context set when the serializer is created in a blueprint context
# Update website/docs/customize/blueprints/v1/models.md when used # Update website/developer-docs/blueprints/v1/models.md when used
SERIALIZER_CONTEXT_BLUEPRINT = "blueprint_entry" SERIALIZER_CONTEXT_BLUEPRINT = "blueprint_entry"
@ -429,7 +429,7 @@ class Importer:
orig_import = deepcopy(self._import) orig_import = deepcopy(self._import)
if self._import.version != 1: if self._import.version != 1:
self.logger.warning("Invalid blueprint version") self.logger.warning("Invalid blueprint version")
return False, [LogEvent("Invalid blueprint version", log_level="warning", logger=None)] return False, [{"event": "Invalid blueprint version"}]
with ( with (
transaction_rollback(), transaction_rollback(),
capture_logs() as logs, capture_logs() as logs,

View File

@ -38,7 +38,6 @@ class ProviderSerializer(ModelSerializer, MetaNameSerializer):
"name", "name",
"authentication_flow", "authentication_flow",
"authorization_flow", "authorization_flow",
"invalidation_flow",
"property_mappings", "property_mappings",
"component", "component",
"assigned_application_slug", "assigned_application_slug",
@ -51,7 +50,6 @@ class ProviderSerializer(ModelSerializer, MetaNameSerializer):
] ]
extra_kwargs = { extra_kwargs = {
"authorization_flow": {"required": True, "allow_null": False}, "authorization_flow": {"required": True, "allow_null": False},
"invalidation_flow": {"required": True, "allow_null": False},
} }

View File

@ -679,10 +679,7 @@ class UserViewSet(UsedByMixin, ModelViewSet):
LOGGER.debug("User attempted to impersonate", user=request.user) LOGGER.debug("User attempted to impersonate", user=request.user)
return Response(status=401) return Response(status=401)
user_to_be = self.get_object() user_to_be = self.get_object()
# Check both object-level perms and global perms if not request.user.has_perm("impersonate", user_to_be):
if not request.user.has_perm(
"authentik_core.impersonate", user_to_be
) and not request.user.has_perm("authentik_core.impersonate"):
LOGGER.debug("User attempted to impersonate without permissions", user=request.user) LOGGER.debug("User attempted to impersonate without permissions", user=request.user)
return Response(status=401) return Response(status=401)
if user_to_be.pk == self.request.user.pk: if user_to_be.pk == self.request.user.pk:

View File

@ -1,55 +0,0 @@
# Generated by Django 5.0.9 on 2024-10-02 11:35
import django.db.models.deletion
from django.db import migrations, models
from django.apps.registry import Apps
from django.db import migrations, models
from django.db.backends.base.schema import BaseDatabaseSchemaEditor
def migrate_invalidation_flow_default(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
from authentik.flows.models import FlowDesignation, FlowAuthenticationRequirement
db_alias = schema_editor.connection.alias
Flow = apps.get_model("authentik_flows", "Flow")
Provider = apps.get_model("authentik_core", "Provider")
# So this flow is managed via a blueprint, bue we're in a migration so we don't want to rely on that
# since the blueprint is just an empty flow we can just create it here
# and let it be managed by the blueprint later
flow, _ = Flow.objects.using(db_alias).update_or_create(
slug="default-provider-invalidation-flow",
defaults={
"name": "Logged out of application",
"title": "You've logged out of %(app)s.",
"authentication": FlowAuthenticationRequirement.NONE,
"designation": FlowDesignation.INVALIDATION,
},
)
Provider.objects.using(db_alias).filter(invalidation_flow=None).update(invalidation_flow=flow)
class Migration(migrations.Migration):
dependencies = [
("authentik_core", "0039_source_group_matching_mode_alter_group_name_and_more"),
("authentik_flows", "0027_auto_20231028_1424"),
]
operations = [
migrations.AddField(
model_name="provider",
name="invalidation_flow",
field=models.ForeignKey(
default=None,
help_text="Flow used ending the session from a provider.",
null=True,
on_delete=django.db.models.deletion.SET_DEFAULT,
related_name="provider_invalidation",
to="authentik_flows.flow",
),
),
migrations.RunPython(migrate_invalidation_flow_default),
]

View File

@ -23,6 +23,7 @@ from model_utils.managers import InheritanceManager
from rest_framework.serializers import Serializer from rest_framework.serializers import Serializer
from structlog.stdlib import get_logger from structlog.stdlib import get_logger
from authentik.analytics.models import AnalyticsMixin
from authentik.blueprints.models import ManagedModel 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
@ -168,7 +169,7 @@ class GroupQuerySet(CTEQuerySet):
return cte.join(Group, group_uuid=cte.col.group_uuid).with_cte(cte) return cte.join(Group, group_uuid=cte.col.group_uuid).with_cte(cte)
class Group(SerializerModel, AttributesMixin): class Group(SerializerModel, AttributesMixin, AnalyticsMixin):
"""Group model which supports a basic hierarchy and has attributes""" """Group model which supports a basic hierarchy and has attributes"""
group_uuid = models.UUIDField(primary_key=True, editable=False, default=uuid4) group_uuid = models.UUIDField(primary_key=True, editable=False, default=uuid4)
@ -258,7 +259,7 @@ class UserManager(DjangoUserManager):
return self.get_queryset().exclude_anonymous() return self.get_queryset().exclude_anonymous()
class User(SerializerModel, GuardianUserMixin, AttributesMixin, AbstractUser): class User(SerializerModel, GuardianUserMixin, AttributesMixin, AbstractUser, AnalyticsMixin):
"""authentik User model, based on django's contrib auth user model.""" """authentik User model, based on django's contrib auth user model."""
uuid = models.UUIDField(default=uuid4, editable=False, unique=True) uuid = models.UUIDField(default=uuid4, editable=False, unique=True)
@ -376,7 +377,7 @@ class User(SerializerModel, GuardianUserMixin, AttributesMixin, AbstractUser):
return get_avatar(self) return get_avatar(self)
class Provider(SerializerModel): class Provider(SerializerModel, AnalyticsMixin):
"""Application-independent Provider instance. For example SAML2 Remote, OAuth2 Application""" """Application-independent Provider instance. For example SAML2 Remote, OAuth2 Application"""
name = models.TextField(unique=True) name = models.TextField(unique=True)
@ -391,23 +392,14 @@ class Provider(SerializerModel):
), ),
related_name="provider_authentication", related_name="provider_authentication",
) )
authorization_flow = models.ForeignKey( authorization_flow = models.ForeignKey(
"authentik_flows.Flow", "authentik_flows.Flow",
# Set to cascade even though null is allowed, since most providers
# still require an authorization flow set
on_delete=models.CASCADE, on_delete=models.CASCADE,
null=True, null=True,
help_text=_("Flow used when authorizing this provider."), help_text=_("Flow used when authorizing this provider."),
related_name="provider_authorization", related_name="provider_authorization",
) )
invalidation_flow = models.ForeignKey(
"authentik_flows.Flow",
on_delete=models.SET_DEFAULT,
default=None,
null=True,
help_text=_("Flow used ending the session from a provider."),
related_name="provider_invalidation",
)
property_mappings = models.ManyToManyField("PropertyMapping", default=None, blank=True) property_mappings = models.ManyToManyField("PropertyMapping", default=None, blank=True)
@ -479,7 +471,7 @@ class ApplicationQuerySet(QuerySet):
return qs return qs
class Application(SerializerModel, PolicyBindingModel): class Application(SerializerModel, PolicyBindingModel, AnalyticsMixin):
"""Every Application which uses authentik for authentication/identification/authorization """Every Application which uses authentik for authentication/identification/authorization
needs an Application record. Other authentication types can subclass this Model to needs an Application record. Other authentication types can subclass this Model to
add custom fields and other properties""" add custom fields and other properties"""
@ -612,7 +604,7 @@ class SourceGroupMatchingModes(models.TextChoices):
) )
class Source(ManagedModel, SerializerModel, PolicyBindingModel): class Source(ManagedModel, SerializerModel, PolicyBindingModel, AnalyticsMixin):
"""Base Authentication source, i.e. an OAuth Provider, SAML Remote or LDAP Server""" """Base Authentication source, i.e. an OAuth Provider, SAML Remote or LDAP Server"""
name = models.TextField(help_text=_("Source's display Name.")) name = models.TextField(help_text=_("Source's display Name."))
@ -744,7 +736,7 @@ class Source(ManagedModel, SerializerModel, PolicyBindingModel):
] ]
class UserSourceConnection(SerializerModel, CreatedUpdatedModel): class UserSourceConnection(SerializerModel, CreatedUpdatedModel, AnalyticsMixin):
"""Connection between User and Source.""" """Connection between User and Source."""
user = models.ForeignKey(User, on_delete=models.CASCADE) user = models.ForeignKey(User, on_delete=models.CASCADE)
@ -764,7 +756,7 @@ class UserSourceConnection(SerializerModel, CreatedUpdatedModel):
unique_together = (("user", "source"),) unique_together = (("user", "source"),)
class GroupSourceConnection(SerializerModel, CreatedUpdatedModel): class GroupSourceConnection(SerializerModel, CreatedUpdatedModel, AnalyticsMixin):
"""Connection between Group and Source.""" """Connection between Group and Source."""
group = models.ForeignKey(Group, on_delete=models.CASCADE) group = models.ForeignKey(Group, on_delete=models.CASCADE)
@ -802,25 +794,12 @@ class ExpiringModel(models.Model):
return self.delete(*args, **kwargs) return self.delete(*args, **kwargs)
@classmethod @classmethod
def _not_expired_filter(cls): def filter_not_expired(cls, **kwargs) -> QuerySet["Token"]:
return Q(expires__gt=now(), expiring=True) | Q(expiring=False)
@classmethod
def filter_not_expired(cls, delete_expired=False, **kwargs) -> QuerySet["ExpiringModel"]:
"""Filer for tokens which are not expired yet or are not expiring, """Filer for tokens which are not expired yet or are not expiring,
and match filters in `kwargs`""" and match filters in `kwargs`"""
if delete_expired: for obj in cls.objects.filter(**kwargs).filter(Q(expires__lt=now(), expiring=True)):
cls.delete_expired(**kwargs) obj.delete()
return cls.objects.filter(cls._not_expired_filter()).filter(**kwargs) return cls.objects.filter(**kwargs)
@classmethod
def delete_expired(cls, **kwargs) -> int:
objects = cls.objects.all().exclude(cls._not_expired_filter()).filter(**kwargs)
amount = 0
for obj in objects:
obj.expire_action()
amount += 1
return amount
@property @property
def is_expired(self) -> bool: def is_expired(self) -> bool:
@ -901,7 +880,7 @@ class Token(SerializerModel, ManagedModel, ExpiringModel):
).save() ).save()
class PropertyMapping(SerializerModel, ManagedModel): class PropertyMapping(SerializerModel, ManagedModel, AnalyticsMixin):
"""User-defined key -> x mapping which can be used by providers to expose extra data.""" """User-defined key -> x mapping which can be used by providers to expose extra data."""
pm_uuid = models.UUIDField(primary_key=True, editable=False, default=uuid4) pm_uuid = models.UUIDField(primary_key=True, editable=False, default=uuid4)

View File

@ -30,7 +30,12 @@ def clean_expired_models(self: SystemTask):
messages = [] messages = []
for cls in ExpiringModel.__subclasses__(): for cls in ExpiringModel.__subclasses__():
cls: ExpiringModel cls: ExpiringModel
amount = cls.delete_expired() objects = (
cls.objects.all().exclude(expiring=False).exclude(expiring=True, expires__gt=now())
)
amount = objects.count()
for obj in objects:
obj.expire_action()
LOGGER.debug("Expired models", model=cls, amount=amount) LOGGER.debug("Expired models", model=cls, amount=amount)
messages.append(f"Expired {amount} {cls._meta.verbose_name_plural}") messages.append(f"Expired {amount} {cls._meta.verbose_name_plural}")
# Special case # Special case

View File

@ -0,0 +1,43 @@
{% extends 'login/base_full.html' %}
{% load static %}
{% load i18n %}
{% block title %}
{% trans 'End session' %} - {{ brand.branding_title }}
{% endblock %}
{% block card_title %}
{% blocktrans with application=application.name %}
You've logged out of {{ application }}.
{% endblocktrans %}
{% endblock %}
{% block card %}
<form method="POST" class="pf-c-form">
<p>
{% blocktrans with application=application.name branding_title=brand.branding_title %}
You've logged out of {{ application }}. You can go back to the overview to launch another application, or log out of your {{ branding_title }} account.
{% endblocktrans %}
</p>
<a id="ak-back-home" href="{% url 'authentik_core:root-redirect' %}" class="pf-c-button pf-m-primary">
{% trans 'Go back to overview' %}
</a>
<a id="logout" href="{% url 'authentik_flows:default-invalidation' %}" class="pf-c-button pf-m-secondary">
{% blocktrans with branding_title=brand.branding_title %}
Log out of {{ branding_title }}
{% endblocktrans %}
</a>
{% if application.get_launch_url %}
<a href="{{ application.get_launch_url }}" class="pf-c-button pf-m-secondary">
{% blocktrans with application=application.name %}
Log back into {{ application }}
{% endblocktrans %}
</a>
{% endif %}
</form>
{% endblock %}

View File

@ -134,7 +134,6 @@ class TestApplicationsAPI(APITestCase):
"assigned_application_name": "allowed", "assigned_application_name": "allowed",
"assigned_application_slug": "allowed", "assigned_application_slug": "allowed",
"authentication_flow": None, "authentication_flow": None,
"invalidation_flow": None,
"authorization_flow": str(self.provider.authorization_flow.pk), "authorization_flow": str(self.provider.authorization_flow.pk),
"component": "ak-provider-oauth2-form", "component": "ak-provider-oauth2-form",
"meta_model_name": "authentik_providers_oauth2.oauth2provider", "meta_model_name": "authentik_providers_oauth2.oauth2provider",
@ -187,7 +186,6 @@ class TestApplicationsAPI(APITestCase):
"assigned_application_name": "allowed", "assigned_application_name": "allowed",
"assigned_application_slug": "allowed", "assigned_application_slug": "allowed",
"authentication_flow": None, "authentication_flow": None,
"invalidation_flow": None,
"authorization_flow": str(self.provider.authorization_flow.pk), "authorization_flow": str(self.provider.authorization_flow.pk),
"component": "ak-provider-oauth2-form", "component": "ak-provider-oauth2-form",
"meta_model_name": "authentik_providers_oauth2.oauth2provider", "meta_model_name": "authentik_providers_oauth2.oauth2provider",

View File

@ -44,26 +44,6 @@ class TestImpersonation(APITestCase):
self.assertEqual(response_body["user"]["username"], self.user.username) self.assertEqual(response_body["user"]["username"], self.user.username)
self.assertNotIn("original", response_body) self.assertNotIn("original", response_body)
def test_impersonate_global(self):
"""Test impersonation with global permissions"""
new_user = create_test_user()
assign_perm("authentik_core.impersonate", new_user)
assign_perm("authentik_core.view_user", new_user)
self.client.force_login(new_user)
response = self.client.post(
reverse(
"authentik_api:user-impersonate",
kwargs={"pk": self.other_user.pk},
)
)
self.assertEqual(response.status_code, 201)
response = self.client.get(reverse("authentik_api:user-me"))
response_body = loads(response.content.decode())
self.assertEqual(response_body["user"]["username"], self.other_user.username)
self.assertEqual(response_body["original"]["username"], new_user.username)
def test_impersonate_scoped(self): def test_impersonate_scoped(self):
"""Test impersonation with scoped permissions""" """Test impersonation with scoped permissions"""
new_user = create_test_user() new_user = create_test_user()

View File

@ -19,6 +19,7 @@ class TestTransactionalApplicationsAPI(APITestCase):
"""Test transactional Application + provider creation""" """Test transactional Application + provider creation"""
self.client.force_login(self.user) self.client.force_login(self.user)
uid = generate_id() uid = generate_id()
authorization_flow = create_test_flow()
response = self.client.put( response = self.client.put(
reverse("authentik_api:core-transactional-application"), reverse("authentik_api:core-transactional-application"),
data={ data={
@ -29,8 +30,7 @@ class TestTransactionalApplicationsAPI(APITestCase):
"provider_model": "authentik_providers_oauth2.oauth2provider", "provider_model": "authentik_providers_oauth2.oauth2provider",
"provider": { "provider": {
"name": uid, "name": uid,
"authorization_flow": str(create_test_flow().pk), "authorization_flow": str(authorization_flow.pk),
"invalidation_flow": str(create_test_flow().pk),
}, },
}, },
) )
@ -56,16 +56,10 @@ class TestTransactionalApplicationsAPI(APITestCase):
"provider": { "provider": {
"name": uid, "name": uid,
"authorization_flow": "", "authorization_flow": "",
"invalidation_flow": "",
}, },
}, },
) )
self.assertJSONEqual( self.assertJSONEqual(
response.content.decode(), response.content.decode(),
{ {"provider": {"authorization_flow": ["This field may not be null."]}},
"provider": {
"authorization_flow": ["This field may not be null."],
"invalidation_flow": ["This field may not be null."],
}
},
) )

View File

@ -24,6 +24,7 @@ from authentik.core.views.interface import (
InterfaceView, InterfaceView,
RootRedirectView, RootRedirectView,
) )
from authentik.core.views.session import EndSessionView
from authentik.flows.views.interface import FlowInterfaceView 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
@ -59,6 +60,11 @@ urlpatterns = [
ensure_csrf_cookie(FlowInterfaceView.as_view()), ensure_csrf_cookie(FlowInterfaceView.as_view()),
name="if-flow", name="if-flow",
), ),
path(
"if/session-end/<slug:application_slug>/",
ensure_csrf_cookie(EndSessionView.as_view()),
name="if-session-end",
),
# Fallback for WS # Fallback for WS
path("ws/outpost/<uuid:pk>/", InterfaceView.as_view(template_name="if/admin.html")), path("ws/outpost/<uuid:pk>/", InterfaceView.as_view(template_name="if/admin.html")),
path( path(

View File

@ -0,0 +1,23 @@
"""authentik Session Views"""
from typing import Any
from django.shortcuts import get_object_or_404
from django.views.generic.base import TemplateView
from authentik.core.models import Application
from authentik.policies.views import PolicyAccessView
class EndSessionView(TemplateView, PolicyAccessView):
"""Allow the client to end the Session"""
template_name = "if/end_session.html"
def resolve_provider_application(self):
self.application = get_object_or_404(Application, slug=self.kwargs["application_slug"])
def get_context_data(self, **kwargs: Any) -> dict[str, Any]:
context = super().get_context_data(**kwargs)
context["application"] = self.application
return context

View File

@ -68,7 +68,6 @@ class TestEndpointsAPI(APITestCase):
"name": self.provider.name, "name": self.provider.name,
"authentication_flow": None, "authentication_flow": None,
"authorization_flow": None, "authorization_flow": None,
"invalidation_flow": None,
"property_mappings": [], "property_mappings": [],
"connection_expiry": "hours=8", "connection_expiry": "hours=8",
"delete_token_on_disconnect": False, "delete_token_on_disconnect": False,
@ -121,7 +120,6 @@ class TestEndpointsAPI(APITestCase):
"name": self.provider.name, "name": self.provider.name,
"authentication_flow": None, "authentication_flow": None,
"authorization_flow": None, "authorization_flow": None,
"invalidation_flow": None,
"property_mappings": [], "property_mappings": [],
"component": "ak-provider-rac-form", "component": "ak-provider-rac-form",
"assigned_application_slug": self.app.slug, "assigned_application_slug": self.app.slug,
@ -151,7 +149,6 @@ class TestEndpointsAPI(APITestCase):
"name": self.provider.name, "name": self.provider.name,
"authentication_flow": None, "authentication_flow": None,
"authorization_flow": None, "authorization_flow": None,
"invalidation_flow": None,
"property_mappings": [], "property_mappings": [],
"component": "ak-provider-rac-form", "component": "ak-provider-rac-form",
"assigned_application_slug": self.app.slug, "assigned_application_slug": self.app.slug,

View File

@ -50,7 +50,7 @@ class ASNContextProcessor(MMDBContextProcessor):
"""Wrapper for Reader.asn""" """Wrapper for Reader.asn"""
with start_span( with start_span(
op="authentik.events.asn.asn", op="authentik.events.asn.asn",
name=ip_address, description=ip_address,
): ):
if not self.configured(): if not self.configured():
return None return None

View File

@ -51,7 +51,7 @@ class GeoIPContextProcessor(MMDBContextProcessor):
"""Wrapper for Reader.city""" """Wrapper for Reader.city"""
with start_span( with start_span(
op="authentik.events.geo.city", op="authentik.events.geo.city",
name=ip_address, description=ip_address,
): ):
if not self.configured(): if not self.configured():
return None return None

View File

@ -0,0 +1,49 @@
# Generated by Django 5.0.9 on 2024-09-25 11:06
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("authentik_events", "0007_event_authentik_e_action_9a9dd9_idx_and_more"),
]
operations = [
migrations.AlterField(
model_name="event",
name="action",
field=models.TextField(
choices=[
("login", "Login"),
("login_failed", "Login Failed"),
("logout", "Logout"),
("user_write", "User Write"),
("suspicious_request", "Suspicious Request"),
("password_set", "Password Set"),
("secret_view", "Secret View"),
("secret_rotate", "Secret Rotate"),
("invitation_used", "Invite Used"),
("authorize_application", "Authorize Application"),
("source_linked", "Source Linked"),
("impersonation_started", "Impersonation Started"),
("impersonation_ended", "Impersonation Ended"),
("flow_execution", "Flow Execution"),
("policy_execution", "Policy Execution"),
("policy_exception", "Policy Exception"),
("property_mapping_exception", "Property Mapping Exception"),
("system_task_execution", "System Task Execution"),
("system_task_exception", "System Task Exception"),
("system_exception", "System Exception"),
("configuration_error", "Configuration Error"),
("model_created", "Model Created"),
("model_updated", "Model Updated"),
("model_deleted", "Model Deleted"),
("email_sent", "Email Sent"),
("analytics_sent", "Analytics Sent"),
("update_available", "Update Available"),
("custom_", "Custom Prefix"),
]
),
),
]

View File

@ -119,6 +119,7 @@ class EventAction(models.TextChoices):
MODEL_DELETED = "model_deleted" MODEL_DELETED = "model_deleted"
EMAIL_SENT = "email_sent" EMAIL_SENT = "email_sent"
ANALYTICS_SENT = "analytics_sent"
UPDATE_AVAILABLE = "update_available" UPDATE_AVAILABLE = "update_available"
CUSTOM_PREFIX = "custom_" CUSTOM_PREFIX = "custom_"

View File

@ -110,21 +110,8 @@ class FlowErrorChallenge(Challenge):
class AccessDeniedChallenge(WithUserInfoChallenge): class AccessDeniedChallenge(WithUserInfoChallenge):
"""Challenge when a flow's active stage calls `stage_invalid()`.""" """Challenge when a flow's active stage calls `stage_invalid()`."""
component = CharField(default="ak-stage-access-denied")
error_message = CharField(required=False) error_message = CharField(required=False)
component = CharField(default="ak-stage-access-denied")
class SessionEndChallenge(WithUserInfoChallenge):
"""Challenge for ending a session"""
component = CharField(default="ak-stage-session-end")
application_name = CharField(required=False)
application_launch_url = CharField(required=False)
invalidation_flow_url = CharField(required=False)
brand_name = CharField(required=True)
class PermissionDict(TypedDict): class PermissionDict(TypedDict):

View File

@ -6,18 +6,20 @@ from django.db.backends.base.schema import BaseDatabaseSchemaEditor
def set_oobe_flow_authentication(apps: Apps, schema_editor: BaseDatabaseSchemaEditor): def set_oobe_flow_authentication(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
from guardian.conf import settings as guardian_settings from guardian.shortcuts import get_anonymous_user
Flow = apps.get_model("authentik_flows", "Flow") Flow = apps.get_model("authentik_flows", "Flow")
User = apps.get_model("authentik_core", "User") User = apps.get_model("authentik_core", "User")
db_alias = schema_editor.connection.alias db_alias = schema_editor.connection.alias
users = ( users = User.objects.using(db_alias).exclude(username="akadmin")
User.objects.using(db_alias) try:
.exclude(username="akadmin") users = users.exclude(pk=get_anonymous_user().pk)
.exclude(username=guardian_settings.ANONYMOUS_USER_NAME)
) except Exception: # nosec
pass
if users.exists(): if users.exists():
Flow.objects.using(db_alias).filter(slug="initial-setup").update( Flow.objects.using(db_alias).filter(slug="initial-setup").update(
authentication="require_superuser" authentication="require_superuser"

View File

@ -107,9 +107,7 @@ class Stage(SerializerModel):
def in_memory_stage(view: type["StageView"], **kwargs) -> Stage: def in_memory_stage(view: type["StageView"], **kwargs) -> Stage:
"""Creates an in-memory stage instance, based on a `view` as view. """Creates an in-memory stage instance, based on a `view` as view."""
Any key-word arguments are set as attributes on the stage object,
accessible via `self.executor.current_stage`."""
stage = Stage() stage = Stage()
# Because we can't pickle a locally generated function, # Because we can't pickle a locally generated function,
# we set the view as a separate property and reference a generic function # we set the view as a separate property and reference a generic function

View File

@ -166,7 +166,7 @@ class FlowPlanner:
def plan(self, request: HttpRequest, default_context: dict[str, Any] | None = None) -> FlowPlan: def plan(self, request: HttpRequest, default_context: dict[str, Any] | None = None) -> FlowPlan:
"""Check each of the flows' policies, check policies for each stage with PolicyBinding """Check each of the flows' policies, check policies for each stage with PolicyBinding
and return ordered list""" and return ordered list"""
with start_span(op="authentik.flow.planner.plan", name=self.flow.slug) as span: with start_span(op="authentik.flow.planner.plan", description=self.flow.slug) as span:
span: Span span: Span
span.set_data("flow", self.flow) span.set_data("flow", self.flow)
span.set_data("request", request) span.set_data("request", request)
@ -233,7 +233,7 @@ class FlowPlanner:
with ( with (
start_span( start_span(
op="authentik.flow.planner.build_plan", op="authentik.flow.planner.build_plan",
name=self.flow.slug, description=self.flow.slug,
) as span, ) as span,
HIST_FLOWS_PLAN_TIME.labels(flow_slug=self.flow.slug).time(), HIST_FLOWS_PLAN_TIME.labels(flow_slug=self.flow.slug).time(),
): ):

View File

@ -13,7 +13,7 @@ from rest_framework.request import Request
from sentry_sdk import start_span from sentry_sdk import start_span
from structlog.stdlib import BoundLogger, get_logger from structlog.stdlib import BoundLogger, get_logger
from authentik.core.models import Application, User from authentik.core.models import User
from authentik.flows.challenge import ( from authentik.flows.challenge import (
AccessDeniedChallenge, AccessDeniedChallenge,
Challenge, Challenge,
@ -21,7 +21,6 @@ from authentik.flows.challenge import (
ContextualFlowInfo, ContextualFlowInfo,
HttpChallengeResponse, HttpChallengeResponse,
RedirectChallenge, RedirectChallenge,
SessionEndChallenge,
WithUserInfoChallenge, WithUserInfoChallenge,
) )
from authentik.flows.exceptions import StageInvalidException from authentik.flows.exceptions import StageInvalidException
@ -126,7 +125,7 @@ class ChallengeStageView(StageView):
with ( with (
start_span( start_span(
op="authentik.flow.stage.challenge_invalid", op="authentik.flow.stage.challenge_invalid",
name=self.__class__.__name__, description=self.__class__.__name__,
), ),
HIST_FLOWS_STAGE_TIME.labels( HIST_FLOWS_STAGE_TIME.labels(
stage_type=self.__class__.__name__, method="challenge_invalid" stage_type=self.__class__.__name__, method="challenge_invalid"
@ -136,7 +135,7 @@ class ChallengeStageView(StageView):
with ( with (
start_span( start_span(
op="authentik.flow.stage.challenge_valid", op="authentik.flow.stage.challenge_valid",
name=self.__class__.__name__, description=self.__class__.__name__,
), ),
HIST_FLOWS_STAGE_TIME.labels( HIST_FLOWS_STAGE_TIME.labels(
stage_type=self.__class__.__name__, method="challenge_valid" stage_type=self.__class__.__name__, method="challenge_valid"
@ -162,7 +161,7 @@ class ChallengeStageView(StageView):
with ( with (
start_span( start_span(
op="authentik.flow.stage.get_challenge", op="authentik.flow.stage.get_challenge",
name=self.__class__.__name__, description=self.__class__.__name__,
), ),
HIST_FLOWS_STAGE_TIME.labels( HIST_FLOWS_STAGE_TIME.labels(
stage_type=self.__class__.__name__, method="get_challenge" stage_type=self.__class__.__name__, method="get_challenge"
@ -175,7 +174,7 @@ class ChallengeStageView(StageView):
return self.executor.stage_invalid() return self.executor.stage_invalid()
with start_span( with start_span(
op="authentik.flow.stage._get_challenge", op="authentik.flow.stage._get_challenge",
name=self.__class__.__name__, description=self.__class__.__name__,
): ):
if not hasattr(challenge, "initial_data"): if not hasattr(challenge, "initial_data"):
challenge.initial_data = {} challenge.initial_data = {}
@ -231,7 +230,7 @@ class ChallengeStageView(StageView):
return HttpChallengeResponse(challenge_response) return HttpChallengeResponse(challenge_response)
class AccessDeniedStage(ChallengeStageView): class AccessDeniedChallengeView(ChallengeStageView):
"""Used internally by FlowExecutor's stage_invalid()""" """Used internally by FlowExecutor's stage_invalid()"""
error_message: str | None error_message: str | None
@ -269,31 +268,3 @@ class RedirectStage(ChallengeStageView):
def challenge_valid(self, response: ChallengeResponse) -> HttpResponse: def challenge_valid(self, response: ChallengeResponse) -> HttpResponse:
return HttpChallengeResponse(self.get_challenge()) return HttpChallengeResponse(self.get_challenge())
class SessionEndStage(ChallengeStageView):
"""Stage inserted when a flow is used as invalidation flow. By default shows actions
that the user is likely to take after signing out of a provider."""
def get_challenge(self, *args, **kwargs) -> Challenge:
application: Application | None = self.executor.plan.context.get(PLAN_CONTEXT_APPLICATION)
data = {
"component": "ak-stage-session-end",
"brand_name": self.request.brand.branding_title,
}
if application:
data["application_name"] = application.name
data["application_launch_url"] = application.get_launch_url(self.get_pending_user())
if self.request.brand.flow_invalidation:
data["invalidation_flow_url"] = reverse(
"authentik_core:if-flow",
kwargs={
"flow_slug": self.request.brand.flow_invalidation.slug,
},
)
return SessionEndChallenge(data=data)
# This can never be reached since this challenge is created on demand and only the
# .get() method is called
def challenge_valid(self, response: ChallengeResponse) -> HttpResponse: # pragma: no cover
return self.executor.cancel()

View File

@ -54,7 +54,7 @@ from authentik.flows.planner import (
FlowPlan, FlowPlan,
FlowPlanner, FlowPlanner,
) )
from authentik.flows.stage import AccessDeniedStage, StageView from authentik.flows.stage import AccessDeniedChallengeView, StageView
from authentik.lib.sentry import SentryIgnoredException from authentik.lib.sentry import SentryIgnoredException
from authentik.lib.utils.errors import exception_to_string from authentik.lib.utils.errors import exception_to_string
from authentik.lib.utils.reflection import all_subclasses, class_to_path from authentik.lib.utils.reflection import all_subclasses, class_to_path
@ -153,7 +153,7 @@ class FlowExecutorView(APIView):
return plan return plan
def dispatch(self, request: HttpRequest, flow_slug: str) -> HttpResponse: def dispatch(self, request: HttpRequest, flow_slug: str) -> HttpResponse:
with start_span(op="authentik.flow.executor.dispatch", name=self.flow.slug) as span: with start_span(op="authentik.flow.executor.dispatch", description=self.flow.slug) as span:
span.set_data("authentik Flow", self.flow.slug) span.set_data("authentik Flow", self.flow.slug)
get_params = QueryDict(request.GET.get(QS_QUERY, "")) get_params = QueryDict(request.GET.get(QS_QUERY, ""))
if QS_KEY_TOKEN in get_params: if QS_KEY_TOKEN in get_params:
@ -273,7 +273,7 @@ class FlowExecutorView(APIView):
with ( with (
start_span( start_span(
op="authentik.flow.executor.stage", op="authentik.flow.executor.stage",
name=class_path, description=class_path,
) as span, ) as span,
HIST_FLOW_EXECUTION_STAGE_TIME.labels( HIST_FLOW_EXECUTION_STAGE_TIME.labels(
method=request.method.upper(), method=request.method.upper(),
@ -324,7 +324,7 @@ class FlowExecutorView(APIView):
with ( with (
start_span( start_span(
op="authentik.flow.executor.stage", op="authentik.flow.executor.stage",
name=class_path, description=class_path,
) as span, ) as span,
HIST_FLOW_EXECUTION_STAGE_TIME.labels( HIST_FLOW_EXECUTION_STAGE_TIME.labels(
method=request.method.upper(), method=request.method.upper(),
@ -441,7 +441,7 @@ class FlowExecutorView(APIView):
) )
return self.restart_flow(keep_context) return self.restart_flow(keep_context)
self.cancel() self.cancel()
challenge_view = AccessDeniedStage(self, error_message) challenge_view = AccessDeniedChallengeView(self, error_message)
challenge_view.request = self.request challenge_view.request = self.request
return to_stage_response(self.request, challenge_view.get(self.request)) return to_stage_response(self.request, challenge_view.get(self.request))

View File

@ -1,4 +1,4 @@
# update website/docs/install-config/configuration/configuration.mdx # update website/docs/installation/configuration.mdx
# This is the default configuration file # This is the default configuration file
postgresql: postgresql:
host: localhost host: localhost

View File

@ -30,11 +30,6 @@ class TestHTTP(TestCase):
request = self.factory.get("/", HTTP_X_FORWARDED_FOR="127.0.0.2") request = self.factory.get("/", HTTP_X_FORWARDED_FOR="127.0.0.2")
self.assertEqual(ClientIPMiddleware.get_client_ip(request), "127.0.0.2") self.assertEqual(ClientIPMiddleware.get_client_ip(request), "127.0.0.2")
def test_forward_for_invalid(self):
"""Test invalid forward for"""
request = self.factory.get("/", HTTP_X_FORWARDED_FOR="foobar")
self.assertEqual(ClientIPMiddleware.get_client_ip(request), ClientIPMiddleware.default_ip)
def test_fake_outpost(self): def test_fake_outpost(self):
"""Test faked IP which is overridden by an outpost""" """Test faked IP which is overridden by an outpost"""
token = Token.objects.create( token = Token.objects.create(
@ -58,17 +53,6 @@ class TestHTTP(TestCase):
}, },
) )
self.assertEqual(ClientIPMiddleware.get_client_ip(request), "127.0.0.1") self.assertEqual(ClientIPMiddleware.get_client_ip(request), "127.0.0.1")
# Invalid, not a real IP
self.user.type = UserTypes.INTERNAL_SERVICE_ACCOUNT
self.user.save()
request = self.factory.get(
"/",
**{
ClientIPMiddleware.outpost_remote_ip_header: "foobar",
ClientIPMiddleware.outpost_token_header: token.key,
},
)
self.assertEqual(ClientIPMiddleware.get_client_ip(request), "127.0.0.1")
# Valid # Valid
self.user.type = UserTypes.INTERNAL_SERVICE_ACCOUNT self.user.type = UserTypes.INTERNAL_SERVICE_ACCOUNT
self.user.save() self.user.save()

View File

@ -9,7 +9,7 @@ from uuid import uuid4
from dacite.core import from_dict from dacite.core import from_dict
from django.contrib.auth.models import Permission from django.contrib.auth.models import Permission
from django.core.cache import cache from django.core.cache import cache
from django.db import models, transaction from django.db import IntegrityError, models, transaction
from django.db.models.base import Model from django.db.models.base import Model
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from guardian.models import UserObjectPermission from guardian.models import UserObjectPermission
@ -53,7 +53,7 @@ class ServiceConnectionInvalid(SentryIgnoredException):
class OutpostConfig: class OutpostConfig:
"""Configuration an outpost uses to configure it self""" """Configuration an outpost uses to configure it self"""
# update website/docs/add-secure-apps/outposts/_config.md # update website/docs/outposts/_config.md
authentik_host: str = "" authentik_host: str = ""
authentik_host_insecure: bool = False authentik_host_insecure: bool = False
@ -380,22 +380,26 @@ class Outpost(SerializerModel, ManagedModel):
"""Get/create token for auto-generated user""" """Get/create token for auto-generated user"""
managed = f"goauthentik.io/outpost/{self.token_identifier}" managed = f"goauthentik.io/outpost/{self.token_identifier}"
tokens = Token.filter_not_expired( tokens = Token.filter_not_expired(
delete_expired=True,
identifier=self.token_identifier, identifier=self.token_identifier,
intent=TokenIntents.INTENT_API, intent=TokenIntents.INTENT_API,
managed=managed, managed=managed,
) )
token: Token | None = tokens.first() if tokens.exists():
if token: return tokens.first()
return token try:
return Token.objects.create( return Token.objects.create(
user=self.user, user=self.user,
identifier=self.token_identifier, identifier=self.token_identifier,
intent=TokenIntents.INTENT_API, intent=TokenIntents.INTENT_API,
description=f"Autogenerated by authentik for Outpost {self.name}", description=f"Autogenerated by authentik for Outpost {self.name}",
expiring=False, expiring=False,
managed=managed, managed=managed,
) )
except IntegrityError:
# Integrity error happens mostly when managed is reused
Token.objects.filter(managed=managed).delete()
Token.objects.filter(identifier=self.token_identifier).delete()
return self.token
def get_required_objects(self) -> Iterable[models.Model | str]: def get_required_objects(self) -> Iterable[models.Model | str]:
"""Get an iterator of all objects the user needs read access to""" """Get an iterator of all objects the user needs read access to"""

View File

@ -113,7 +113,7 @@ class PolicyEngine:
with ( with (
start_span( start_span(
op="authentik.policy.engine.build", op="authentik.policy.engine.build",
name=self.__pbm, description=self.__pbm,
) as span, ) as span,
HIST_POLICIES_ENGINE_TOTAL_TIME.labels( HIST_POLICIES_ENGINE_TOTAL_TIME.labels(
obj_type=class_to_path(self.__pbm.__class__), obj_type=class_to_path(self.__pbm.__class__),

View File

@ -0,0 +1,52 @@
# Generated by Django 5.0.9 on 2024-09-25 11:06
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("authentik_policies_event_matcher", "0023_alter_eventmatcherpolicy_action_and_more"),
]
operations = [
migrations.AlterField(
model_name="eventmatcherpolicy",
name="action",
field=models.TextField(
choices=[
("login", "Login"),
("login_failed", "Login Failed"),
("logout", "Logout"),
("user_write", "User Write"),
("suspicious_request", "Suspicious Request"),
("password_set", "Password Set"),
("secret_view", "Secret View"),
("secret_rotate", "Secret Rotate"),
("invitation_used", "Invite Used"),
("authorize_application", "Authorize Application"),
("source_linked", "Source Linked"),
("impersonation_started", "Impersonation Started"),
("impersonation_ended", "Impersonation Ended"),
("flow_execution", "Flow Execution"),
("policy_execution", "Policy Execution"),
("policy_exception", "Policy Exception"),
("property_mapping_exception", "Property Mapping Exception"),
("system_task_execution", "System Task Execution"),
("system_task_exception", "System Task Exception"),
("system_exception", "System Exception"),
("configuration_error", "Configuration Error"),
("model_created", "Model Created"),
("model_updated", "Model Updated"),
("model_deleted", "Model Deleted"),
("email_sent", "Email Sent"),
("analytics_sent", "Analytics Sent"),
("update_available", "Update Available"),
("custom_", "Custom Prefix"),
],
default=None,
help_text="Match created events with this action type. When left empty, all action types will be matched.",
null=True,
),
),
]

View File

@ -87,7 +87,6 @@ class LDAPOutpostConfigSerializer(ModelSerializer):
application_slug = SerializerMethodField() application_slug = SerializerMethodField()
bind_flow_slug = CharField(source="authorization_flow.slug") bind_flow_slug = CharField(source="authorization_flow.slug")
unbind_flow_slug = SerializerMethodField()
def get_application_slug(self, instance: LDAPProvider) -> str: def get_application_slug(self, instance: LDAPProvider) -> str:
"""Prioritise backchannel slug over direct application slug""" """Prioritise backchannel slug over direct application slug"""
@ -95,16 +94,6 @@ class LDAPOutpostConfigSerializer(ModelSerializer):
return instance.backchannel_application.slug return instance.backchannel_application.slug
return instance.application.slug return instance.application.slug
def get_unbind_flow_slug(self, instance: LDAPProvider) -> str | None:
"""Get slug for unbind flow, defaulting to brand's default flow."""
flow = instance.invalidation_flow
if not flow and "request" in self.context:
request = self.context.get("request")
flow = request.brand.flow_invalidation
if not flow:
return None
return flow.slug
class Meta: class Meta:
model = LDAPProvider model = LDAPProvider
fields = [ fields = [
@ -112,7 +101,6 @@ class LDAPOutpostConfigSerializer(ModelSerializer):
"name", "name",
"base_dn", "base_dn",
"bind_flow_slug", "bind_flow_slug",
"unbind_flow_slug",
"application_slug", "application_slug",
"certificate", "certificate",
"tls_server_name", "tls_server_name",

View File

@ -1,23 +0,0 @@
# Generated by Django 5.0.9 on 2024-09-26 16:25
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("authentik_providers_oauth2", "0018_alter_accesstoken_expires_and_more"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddIndex(
model_name="accesstoken",
index=models.Index(fields=["token"], name="authentik_p_token_4bc870_idx"),
),
migrations.AddIndex(
model_name="refreshtoken",
index=models.Index(fields=["token"], name="authentik_p_token_1a841f_idx"),
),
]

View File

@ -1,31 +0,0 @@
# Generated by Django 5.0.9 on 2024-09-27 14:50
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("authentik_providers_oauth2", "0019_accesstoken_authentik_p_token_4bc870_idx_and_more"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.RemoveIndex(
model_name="accesstoken",
name="authentik_p_token_4bc870_idx",
),
migrations.RemoveIndex(
model_name="refreshtoken",
name="authentik_p_token_1a841f_idx",
),
migrations.AddIndex(
model_name="accesstoken",
index=models.Index(fields=["token", "provider"], name="authentik_p_token_f99422_idx"),
),
migrations.AddIndex(
model_name="refreshtoken",
index=models.Index(fields=["token", "provider"], name="authentik_p_token_a1d921_idx"),
),
]

View File

@ -376,9 +376,6 @@ class AccessToken(SerializerModel, ExpiringModel, BaseGrantModel):
_id_token = models.TextField() _id_token = models.TextField()
class Meta: class Meta:
indexes = [
models.Index(fields=["token", "provider"]),
]
verbose_name = _("OAuth2 Access Token") verbose_name = _("OAuth2 Access Token")
verbose_name_plural = _("OAuth2 Access Tokens") verbose_name_plural = _("OAuth2 Access Tokens")
@ -422,9 +419,6 @@ class RefreshToken(SerializerModel, ExpiringModel, BaseGrantModel):
_id_token = models.TextField(verbose_name=_("ID Token")) _id_token = models.TextField(verbose_name=_("ID Token"))
class Meta: class Meta:
indexes = [
models.Index(fields=["token", "provider"]),
]
verbose_name = _("OAuth2 Refresh Token") verbose_name = _("OAuth2 Refresh Token")
verbose_name_plural = _("OAuth2 Refresh Tokens") verbose_name_plural = _("OAuth2 Refresh Tokens")

View File

@ -29,6 +29,7 @@ class TesOAuth2Introspection(OAuthTestCase):
self.app = Application.objects.create( self.app = Application.objects.create(
name=generate_id(), slug=generate_id(), provider=self.provider name=generate_id(), slug=generate_id(), provider=self.provider
) )
self.app.save()
self.user = create_test_admin_user() self.user = create_test_admin_user()
self.auth = b64encode( self.auth = b64encode(
f"{self.provider.client_id}:{self.provider.client_secret}".encode() f"{self.provider.client_id}:{self.provider.client_secret}".encode()
@ -113,41 +114,6 @@ class TesOAuth2Introspection(OAuthTestCase):
}, },
) )
def test_introspect_invalid_provider(self):
"""Test introspection (mismatched provider and token)"""
provider: OAuth2Provider = OAuth2Provider.objects.create(
name=generate_id(),
authorization_flow=create_test_flow(),
redirect_uris="",
signing_key=create_test_cert(),
)
auth = b64encode(f"{provider.client_id}:{provider.client_secret}".encode()).decode()
token: AccessToken = AccessToken.objects.create(
provider=self.provider,
user=self.user,
token=generate_id(),
auth_time=timezone.now(),
_scope="openid user profile",
_id_token=json.dumps(
asdict(
IDToken("foo", "bar"),
)
),
)
res = self.client.post(
reverse("authentik_providers_oauth2:token-introspection"),
HTTP_AUTHORIZATION=f"Basic {auth}",
data={"token": token.token},
)
self.assertEqual(res.status_code, 200)
self.assertJSONEqual(
res.content.decode(),
{
"active": False,
},
)
def test_introspect_invalid_auth(self): def test_introspect_invalid_auth(self):
"""Test introspect (invalid auth)""" """Test introspect (invalid auth)"""
res = self.client.post( res = self.client.post(

View File

@ -12,7 +12,6 @@ from authentik.providers.oauth2.api.tokens import (
) )
from authentik.providers.oauth2.views.authorize import AuthorizationFlowInitView from authentik.providers.oauth2.views.authorize import AuthorizationFlowInitView
from authentik.providers.oauth2.views.device_backchannel import DeviceView from authentik.providers.oauth2.views.device_backchannel import DeviceView
from authentik.providers.oauth2.views.end_session import EndSessionView
from authentik.providers.oauth2.views.introspection import TokenIntrospectionView from authentik.providers.oauth2.views.introspection import TokenIntrospectionView
from authentik.providers.oauth2.views.jwks import JWKSView from authentik.providers.oauth2.views.jwks import JWKSView
from authentik.providers.oauth2.views.provider import ProviderInfoView from authentik.providers.oauth2.views.provider import ProviderInfoView
@ -45,7 +44,7 @@ urlpatterns = [
), ),
path( path(
"<slug:application_slug>/end-session/", "<slug:application_slug>/end-session/",
EndSessionView.as_view(), RedirectView.as_view(pattern_name="authentik_core:if-session-end", query_string=True),
name="end-session", name="end-session",
), ),
path("<slug:application_slug>/jwks/", JWKSView.as_view(), name="jwks"), path("<slug:application_slug>/jwks/", JWKSView.as_view(), name="jwks"),

View File

@ -1,45 +0,0 @@
"""oauth2 provider end_session Views"""
from django.http import Http404, HttpRequest, HttpResponse
from django.shortcuts import get_object_or_404
from authentik.core.models import Application
from authentik.flows.models import Flow, in_memory_stage
from authentik.flows.planner import PLAN_CONTEXT_APPLICATION, FlowPlanner
from authentik.flows.stage import SessionEndStage
from authentik.flows.views.executor import SESSION_KEY_PLAN
from authentik.lib.utils.urls import redirect_with_qs
from authentik.policies.views import PolicyAccessView
class EndSessionView(PolicyAccessView):
"""Redirect to application's provider's invalidation flow"""
flow: Flow
def resolve_provider_application(self):
self.application = get_object_or_404(Application, slug=self.kwargs["application_slug"])
self.provider = self.application.get_provider()
if not self.provider:
raise Http404
self.flow = self.provider.invalidation_flow or self.request.brand.flow_invalidation
if not self.flow:
raise Http404
def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
"""Dispatch the flow planner for the invalidation flow"""
planner = FlowPlanner(self.flow)
planner.allow_empty_flows = True
plan = planner.plan(
request,
{
PLAN_CONTEXT_APPLICATION: self.application,
},
)
plan.insert_stage(in_memory_stage(SessionEndStage))
request.session[SESSION_KEY_PLAN] = plan
return redirect_with_qs(
"authentik_core:if-flow",
self.request.GET,
flow_slug=self.flow.slug,
)

View File

@ -46,10 +46,10 @@ class TokenIntrospectionParams:
if not provider: if not provider:
raise TokenIntrospectionError raise TokenIntrospectionError
access_token = AccessToken.objects.filter(token=raw_token, provider=provider).first() access_token = AccessToken.objects.filter(token=raw_token).first()
if access_token: if access_token:
return TokenIntrospectionParams(access_token, provider) return TokenIntrospectionParams(access_token, provider)
refresh_token = RefreshToken.objects.filter(token=raw_token, provider=provider).first() refresh_token = RefreshToken.objects.filter(token=raw_token).first()
if refresh_token: if refresh_token:
return TokenIntrospectionParams(refresh_token, provider) return TokenIntrospectionParams(refresh_token, provider)
LOGGER.debug("Token does not exist", token=raw_token) LOGGER.debug("Token does not exist", token=raw_token)

View File

@ -24,7 +24,6 @@ class ProxyProviderTests(APITestCase):
"name": generate_id(), "name": generate_id(),
"mode": ProxyMode.PROXY, "mode": ProxyMode.PROXY,
"authorization_flow": create_test_flow().pk.hex, "authorization_flow": create_test_flow().pk.hex,
"invalidation_flow": create_test_flow().pk.hex,
"external_host": "http://localhost", "external_host": "http://localhost",
"internal_host": "http://localhost", "internal_host": "http://localhost",
"basic_auth_enabled": True, "basic_auth_enabled": True,
@ -42,7 +41,6 @@ class ProxyProviderTests(APITestCase):
"name": generate_id(), "name": generate_id(),
"mode": ProxyMode.PROXY, "mode": ProxyMode.PROXY,
"authorization_flow": create_test_flow().pk.hex, "authorization_flow": create_test_flow().pk.hex,
"invalidation_flow": create_test_flow().pk.hex,
"external_host": "http://localhost", "external_host": "http://localhost",
"internal_host": "http://localhost", "internal_host": "http://localhost",
"basic_auth_enabled": True, "basic_auth_enabled": True,
@ -66,7 +64,6 @@ class ProxyProviderTests(APITestCase):
"name": generate_id(), "name": generate_id(),
"mode": ProxyMode.PROXY, "mode": ProxyMode.PROXY,
"authorization_flow": create_test_flow().pk.hex, "authorization_flow": create_test_flow().pk.hex,
"invalidation_flow": create_test_flow().pk.hex,
"external_host": "http://localhost", "external_host": "http://localhost",
}, },
) )
@ -85,7 +82,6 @@ class ProxyProviderTests(APITestCase):
"name": name, "name": name,
"mode": ProxyMode.PROXY, "mode": ProxyMode.PROXY,
"authorization_flow": create_test_flow().pk.hex, "authorization_flow": create_test_flow().pk.hex,
"invalidation_flow": create_test_flow().pk.hex,
"external_host": "http://localhost", "external_host": "http://localhost",
"internal_host": "http://localhost", "internal_host": "http://localhost",
}, },
@ -103,7 +99,6 @@ class ProxyProviderTests(APITestCase):
"name": name, "name": name,
"mode": ProxyMode.PROXY, "mode": ProxyMode.PROXY,
"authorization_flow": create_test_flow().pk.hex, "authorization_flow": create_test_flow().pk.hex,
"invalidation_flow": create_test_flow().pk.hex,
"external_host": "http://localhost", "external_host": "http://localhost",
"internal_host": "http://localhost", "internal_host": "http://localhost",
}, },
@ -119,7 +114,6 @@ class ProxyProviderTests(APITestCase):
"name": name, "name": name,
"mode": ProxyMode.PROXY, "mode": ProxyMode.PROXY,
"authorization_flow": create_test_flow().pk.hex, "authorization_flow": create_test_flow().pk.hex,
"invalidation_flow": create_test_flow().pk.hex,
"external_host": "http://localhost", "external_host": "http://localhost",
"internal_host": "http://localhost", "internal_host": "http://localhost",
}, },

View File

@ -188,9 +188,6 @@ class SAMLProviderImportSerializer(PassiveSerializer):
authorization_flow = PrimaryKeyRelatedField( authorization_flow = PrimaryKeyRelatedField(
queryset=Flow.objects.filter(designation=FlowDesignation.AUTHORIZATION), queryset=Flow.objects.filter(designation=FlowDesignation.AUTHORIZATION),
) )
invalidation_flow = PrimaryKeyRelatedField(
queryset=Flow.objects.filter(designation=FlowDesignation.INVALIDATION),
)
file = FileField() file = FileField()
@ -280,9 +277,7 @@ class SAMLProviderViewSet(UsedByMixin, ModelViewSet):
try: try:
metadata = ServiceProviderMetadataParser().parse(file.read().decode()) metadata = ServiceProviderMetadataParser().parse(file.read().decode())
metadata.to_provider( metadata.to_provider(
data.validated_data["name"], data.validated_data["name"], data.validated_data["authorization_flow"]
data.validated_data["authorization_flow"],
data.validated_data["invalidation_flow"],
) )
except ValueError as exc: # pragma: no cover except ValueError as exc: # pragma: no cover
LOGGER.warning(str(exc)) LOGGER.warning(str(exc))

View File

@ -49,13 +49,12 @@ class ServiceProviderMetadata:
signing_keypair: CertificateKeyPair | None = None signing_keypair: CertificateKeyPair | None = None
def to_provider( def to_provider(self, name: str, authorization_flow: Flow) -> SAMLProvider:
self, name: str, authorization_flow: Flow, invalidation_flow: Flow
) -> SAMLProvider:
"""Create a SAMLProvider instance from the details. `name` is required, """Create a SAMLProvider instance from the details. `name` is required,
as depending on the metadata CertificateKeypairs might have to be created.""" as depending on the metadata CertificateKeypairs might have to be created."""
provider = SAMLProvider.objects.create( provider = SAMLProvider.objects.create(
name=name, authorization_flow=authorization_flow, invalidation_flow=invalidation_flow name=name,
authorization_flow=authorization_flow,
) )
provider.issuer = self.entity_id provider.issuer = self.entity_id
provider.sp_binding = self.acs_binding provider.sp_binding = self.acs_binding

View File

@ -47,12 +47,11 @@ class TestSAMLProviderAPI(APITestCase):
data={ data={
"name": generate_id(), "name": generate_id(),
"authorization_flow": create_test_flow().pk, "authorization_flow": create_test_flow().pk,
"invalidation_flow": create_test_flow().pk,
"acs_url": "http://localhost", "acs_url": "http://localhost",
"signing_kp": cert.pk, "signing_kp": cert.pk,
}, },
) )
self.assertEqual(response.status_code, 400) self.assertEqual(400, response.status_code)
self.assertJSONEqual( self.assertJSONEqual(
response.content, response.content,
{ {
@ -69,13 +68,12 @@ class TestSAMLProviderAPI(APITestCase):
data={ data={
"name": generate_id(), "name": generate_id(),
"authorization_flow": create_test_flow().pk, "authorization_flow": create_test_flow().pk,
"invalidation_flow": create_test_flow().pk,
"acs_url": "http://localhost", "acs_url": "http://localhost",
"signing_kp": cert.pk, "signing_kp": cert.pk,
"sign_assertion": True, "sign_assertion": True,
}, },
) )
self.assertEqual(response.status_code, 201) self.assertEqual(201, response.status_code)
def test_metadata(self): def test_metadata(self):
"""Test metadata export (normal)""" """Test metadata export (normal)"""
@ -133,7 +131,6 @@ class TestSAMLProviderAPI(APITestCase):
"file": metadata, "file": metadata,
"name": generate_id(), "name": generate_id(),
"authorization_flow": create_test_flow(FlowDesignation.AUTHORIZATION).pk, "authorization_flow": create_test_flow(FlowDesignation.AUTHORIZATION).pk,
"invalidation_flow": create_test_flow(FlowDesignation.INVALIDATION).pk,
}, },
format="multipart", format="multipart",
) )

View File

@ -82,7 +82,7 @@ class TestServiceProviderMetadataParser(TestCase):
def test_simple(self): def test_simple(self):
"""Test simple metadata without Signing""" """Test simple metadata without Signing"""
metadata = ServiceProviderMetadataParser().parse(load_fixture("fixtures/simple.xml")) metadata = ServiceProviderMetadataParser().parse(load_fixture("fixtures/simple.xml"))
provider = metadata.to_provider("test", self.flow, self.flow) provider = metadata.to_provider("test", self.flow)
self.assertEqual(provider.acs_url, "http://localhost:8080/saml/acs") self.assertEqual(provider.acs_url, "http://localhost:8080/saml/acs")
self.assertEqual(provider.issuer, "http://localhost:8080/saml/metadata") self.assertEqual(provider.issuer, "http://localhost:8080/saml/metadata")
self.assertEqual(provider.sp_binding, SAMLBindings.POST) self.assertEqual(provider.sp_binding, SAMLBindings.POST)
@ -95,7 +95,7 @@ class TestServiceProviderMetadataParser(TestCase):
"""Test Metadata with signing cert""" """Test Metadata with signing cert"""
create_test_cert() create_test_cert()
metadata = ServiceProviderMetadataParser().parse(load_fixture("fixtures/cert.xml")) metadata = ServiceProviderMetadataParser().parse(load_fixture("fixtures/cert.xml"))
provider = metadata.to_provider("test", self.flow, self.flow) provider = metadata.to_provider("test", self.flow)
self.assertEqual(provider.acs_url, "http://localhost:8080/apps/user_saml/saml/acs") self.assertEqual(provider.acs_url, "http://localhost:8080/apps/user_saml/saml/acs")
self.assertEqual(provider.issuer, "http://localhost:8080/apps/user_saml/saml/metadata") self.assertEqual(provider.issuer, "http://localhost:8080/apps/user_saml/saml/metadata")
self.assertEqual(provider.sp_binding, SAMLBindings.POST) self.assertEqual(provider.sp_binding, SAMLBindings.POST)

View File

@ -1,8 +1,8 @@
"""SLO Views""" """SLO Views"""
from django.http import Http404, HttpRequest from django.http import HttpRequest
from django.http.response import HttpResponse from django.http.response import HttpResponse
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404, redirect
from django.utils.decorators import method_decorator from django.utils.decorators import method_decorator
from django.views.decorators.clickjacking import xframe_options_sameorigin from django.views.decorators.clickjacking import xframe_options_sameorigin
from django.views.decorators.csrf import csrf_exempt from django.views.decorators.csrf import csrf_exempt
@ -10,11 +10,6 @@ from structlog.stdlib import get_logger
from authentik.core.models import Application from authentik.core.models import Application
from authentik.events.models import Event, EventAction from authentik.events.models import Event, EventAction
from authentik.flows.models import Flow, in_memory_stage
from authentik.flows.planner import PLAN_CONTEXT_APPLICATION, FlowPlanner
from authentik.flows.stage import SessionEndStage
from authentik.flows.views.executor import SESSION_KEY_PLAN
from authentik.lib.utils.urls import redirect_with_qs
from authentik.lib.views import bad_request_message from authentik.lib.views import bad_request_message
from authentik.policies.views import PolicyAccessView from authentik.policies.views import PolicyAccessView
from authentik.providers.saml.exceptions import CannotHandleAssertion from authentik.providers.saml.exceptions import CannotHandleAssertion
@ -33,16 +28,11 @@ class SAMLSLOView(PolicyAccessView):
""" "SAML SLO Base View, which plans a flow and injects our final stage. """ "SAML SLO Base View, which plans a flow and injects our final stage.
Calls get/post handler.""" Calls get/post handler."""
flow: Flow
def resolve_provider_application(self): def resolve_provider_application(self):
self.application = get_object_or_404(Application, slug=self.kwargs["application_slug"]) self.application = get_object_or_404(Application, slug=self.kwargs["application_slug"])
self.provider: SAMLProvider = get_object_or_404( self.provider: SAMLProvider = get_object_or_404(
SAMLProvider, pk=self.application.provider_id SAMLProvider, pk=self.application.provider_id
) )
self.flow = self.provider.invalidation_flow or self.request.brand.flow_invalidation
if not self.flow:
raise Http404
def check_saml_request(self) -> HttpRequest | None: def check_saml_request(self) -> HttpRequest | None:
"""Handler to verify the SAML Request. Must be implemented by a subclass""" """Handler to verify the SAML Request. Must be implemented by a subclass"""
@ -55,20 +45,9 @@ class SAMLSLOView(PolicyAccessView):
method_response = self.check_saml_request() method_response = self.check_saml_request()
if method_response: if method_response:
return method_response return method_response
planner = FlowPlanner(self.flow) return redirect(
planner.allow_empty_flows = True "authentik_core:if-session-end",
plan = planner.plan( application_slug=self.kwargs["application_slug"],
request,
{
PLAN_CONTEXT_APPLICATION: self.application,
},
)
plan.insert_stage(in_memory_stage(SessionEndStage))
request.session[SESSION_KEY_PLAN] = plan
return redirect_with_qs(
"authentik_core:if-flow",
self.request.GET,
flow_slug=self.flow.slug,
) )
def post(self, request: HttpRequest, application_slug: str) -> HttpResponse: def post(self, request: HttpRequest, application_slug: str) -> HttpResponse:

View File

@ -26,7 +26,6 @@ class SCIMProviderSerializer(ProviderSerializer):
"verbose_name_plural", "verbose_name_plural",
"meta_model_name", "meta_model_name",
"url", "url",
"verify_certificates",
"token", "token",
"exclude_users_service_account", "exclude_users_service_account",
"filter_group", "filter_group",

View File

@ -42,7 +42,6 @@ class SCIMClient[TModel: "Model", TConnection: "Model", TSchema: "BaseModel"](
def __init__(self, provider: SCIMProvider): def __init__(self, provider: SCIMProvider):
super().__init__(provider) super().__init__(provider)
self._session = get_http_session() self._session = get_http_session()
self._session.verify = provider.verify_certificates
self.provider = provider self.provider = provider
# Remove trailing slashes as we assume the URL doesn't have any # Remove trailing slashes as we assume the URL doesn't have any
base_url = provider.url base_url = provider.url

View File

@ -1,18 +0,0 @@
# Generated by Django 5.0.9 on 2024-09-19 14:02
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("authentik_providers_scim", "0009_alter_scimmapping_options"),
]
operations = [
migrations.AddField(
model_name="scimprovider",
name="verify_certificates",
field=models.BooleanField(default=True),
),
]

View File

@ -68,7 +68,6 @@ class SCIMProvider(OutgoingSyncProvider, BackchannelProvider):
url = models.TextField(help_text=_("Base URL to SCIM requests, usually ends in /v2")) url = models.TextField(help_text=_("Base URL to SCIM requests, usually ends in /v2"))
token = models.TextField(help_text=_("Authentication token")) token = models.TextField(help_text=_("Authentication token"))
verify_certificates = models.BooleanField(default=True)
property_mappings_group = models.ManyToManyField( property_mappings_group = models.ManyToManyField(
PropertyMapping, PropertyMapping,

View File

@ -22,7 +22,7 @@ def create_admin_group(user: User) -> Group:
return group return group
def create_recovery_token(user: User, expiry: datetime, generated_from: str) -> tuple[Token, str]: def create_recovery_token(user: User, expiry: datetime, generated_from: str) -> (Token, str):
"""Create recovery token and associated link""" """Create recovery token and associated link"""
_now = now() _now = now()
token = Token.objects.create( token = Token.objects.create(

View File

@ -2,7 +2,6 @@
from collections.abc import Callable from collections.abc import Callable
from hashlib import sha512 from hashlib import sha512
from ipaddress import ip_address
from time import perf_counter, time from time import perf_counter, time
from typing import Any from typing import Any
@ -175,7 +174,6 @@ class ClientIPMiddleware:
def __init__(self, get_response: Callable[[HttpRequest], HttpResponse]): def __init__(self, get_response: Callable[[HttpRequest], HttpResponse]):
self.get_response = get_response self.get_response = get_response
self.logger = get_logger().bind()
def _get_client_ip_from_meta(self, meta: dict[str, Any]) -> str: def _get_client_ip_from_meta(self, meta: dict[str, Any]) -> str:
"""Attempt to get the client's IP by checking common HTTP Headers. """Attempt to get the client's IP by checking common HTTP Headers.
@ -187,16 +185,11 @@ class ClientIPMiddleware:
"HTTP_X_FORWARDED_FOR", "HTTP_X_FORWARDED_FOR",
"REMOTE_ADDR", "REMOTE_ADDR",
) )
try: for _header in headers:
for _header in headers: if _header in meta:
if _header in meta: ips: list[str] = meta.get(_header).split(",")
ips: list[str] = meta.get(_header).split(",") return ips[0].strip()
# Ensure the IP parses as a valid IP return self.default_ip
return str(ip_address(ips[0].strip()))
return self.default_ip
except ValueError as exc:
self.logger.debug("Invalid remote IP", exc=exc)
return self.default_ip
# FIXME: this should probably not be in `root` but rather in a middleware in `outposts` # FIXME: this should probably not be in `root` but rather in a middleware in `outposts`
# but for now it's fine # but for now it's fine
@ -233,11 +226,7 @@ class ClientIPMiddleware:
Scope.get_isolation_scope().set_user(sentry_user) Scope.get_isolation_scope().set_user(sentry_user)
# Set the outpost service account on the request # Set the outpost service account on the request
setattr(request, self.request_attr_outpost_user, user) setattr(request, self.request_attr_outpost_user, user)
try: return delegated_ip
return str(ip_address(delegated_ip))
except ValueError as exc:
self.logger.debug("Invalid remote IP from Outpost", exc=exc)
return None
def _get_client_ip(self, request: HttpRequest | None) -> str: def _get_client_ip(self, request: HttpRequest | None) -> str:
"""Attempt to get the client's IP by checking common HTTP Headers. """Attempt to get the client's IP by checking common HTTP Headers.

View File

@ -70,6 +70,7 @@ TENANT_APPS = [
"django.contrib.contenttypes", "django.contrib.contenttypes",
"django.contrib.sessions", "django.contrib.sessions",
"authentik.admin", "authentik.admin",
"authentik.analytics",
"authentik.api", "authentik.api",
"authentik.crypto", "authentik.crypto",
"authentik.flows", "authentik.flows",

View File

@ -3,7 +3,6 @@
from typing import Any from typing import Any
from django.core.cache import cache from django.core.cache import cache
from django.utils.translation import gettext_lazy as _
from drf_spectacular.utils import extend_schema, inline_serializer from drf_spectacular.utils import extend_schema, inline_serializer
from guardian.shortcuts import get_objects_for_user from guardian.shortcuts import get_objects_for_user
from rest_framework.decorators import action from rest_framework.decorators import action
@ -40,8 +39,9 @@ class LDAPSourceSerializer(SourceSerializer):
"""Get cached source connectivity""" """Get cached source connectivity"""
return cache.get(CACHE_KEY_STATUS + source.slug, None) return cache.get(CACHE_KEY_STATUS + source.slug, None)
def validate_sync_users_password(self, sync_users_password: bool) -> bool: def validate(self, attrs: dict[str, Any]) -> dict[str, Any]:
"""Check that only a single source has password_sync on""" """Check that only a single source has password_sync on"""
sync_users_password = attrs.get("sync_users_password", True)
if sync_users_password: if sync_users_password:
sources = LDAPSource.objects.filter(sync_users_password=True) sources = LDAPSource.objects.filter(sync_users_password=True)
if self.instance: if self.instance:
@ -49,31 +49,11 @@ class LDAPSourceSerializer(SourceSerializer):
if sources.exists(): if sources.exists():
raise ValidationError( raise ValidationError(
{ {
"sync_users_password": _( "sync_users_password": (
"Only a single LDAP Source with password synchronization is allowed" "Only a single LDAP Source with password synchronization is allowed"
) )
} }
) )
return sync_users_password
def validate(self, attrs: dict[str, Any]) -> dict[str, Any]:
"""Validate property mappings with sync_ flags"""
types = ["user", "group"]
for type in types:
toggle_value = attrs.get(f"sync_{type}s", False)
mappings_field = f"{type}_property_mappings"
mappings_value = attrs.get(mappings_field, [])
if toggle_value and len(mappings_value) == 0:
raise ValidationError(
{
mappings_field: _(
(
"When 'Sync {type}s' is enabled, '{type}s property "
"mappings' cannot be empty."
).format(type=type)
)
}
)
return super().validate(attrs) return super().validate(attrs)
class Meta: class Meta:
@ -186,12 +166,11 @@ class LDAPSourceViewSet(UsedByMixin, ModelViewSet):
for sync_class in SYNC_CLASSES: for sync_class in SYNC_CLASSES:
class_name = sync_class.name() class_name = sync_class.name()
all_objects.setdefault(class_name, []) all_objects.setdefault(class_name, [])
for page in sync_class(source).get_objects(size_limit=10): for obj in sync_class(source).get_objects(size_limit=10):
for obj in page: obj: dict
obj: dict obj.pop("raw_attributes", None)
obj.pop("raw_attributes", None) obj.pop("raw_dn", None)
obj.pop("raw_dn", None) all_objects[class_name].append(obj)
all_objects[class_name].append(obj)
return Response(data=all_objects) return Response(data=all_objects)

View File

@ -26,16 +26,17 @@ def sync_ldap_source_on_save(sender, instance: LDAPSource, **_):
"""Ensure that source is synced on save (if enabled)""" """Ensure that source is synced on save (if enabled)"""
if not instance.enabled: if not instance.enabled:
return return
ldap_connectivity_check.delay(instance.pk)
# Don't sync sources when they don't have any property mappings. This will only happen if: # Don't sync sources when they don't have any property mappings. This will only happen if:
# - the user forgets to set them or # - the user forgets to set them or
# - the source is newly created, this is the first save event # - the source is newly created, this is the first save event
# and the mappings are created with an m2m event # and the mappings are created with an m2m event
if instance.sync_users and not instance.user_property_mappings.exists(): if (
return not instance.user_property_mappings.exists()
if instance.sync_groups and not instance.group_property_mappings.exists(): or not instance.group_property_mappings.exists()
):
return return
ldap_sync_single.delay(instance.pk) ldap_sync_single.delay(instance.pk)
ldap_connectivity_check.delay(instance.pk)
@receiver(password_validate) @receiver(password_validate)

View File

@ -78,9 +78,7 @@ class MicrosoftActiveDirectory(BaseLDAPSynchronizer):
# /useraccountcontrol-manipulate-account-properties # /useraccountcontrol-manipulate-account-properties
uac_bit = attributes.get("userAccountControl", 512) uac_bit = attributes.get("userAccountControl", 512)
uac = UserAccountControl(uac_bit) uac = UserAccountControl(uac_bit)
is_active = ( is_active = UserAccountControl.ACCOUNTDISABLE not in uac
UserAccountControl.ACCOUNTDISABLE not in uac and UserAccountControl.LOCKOUT not in uac
)
if is_active != user.is_active: if is_active != user.is_active:
user.is_active = is_active user.is_active = is_active
user.save() user.save()

View File

@ -50,35 +50,3 @@ class LDAPAPITests(APITestCase):
} }
) )
self.assertFalse(serializer.is_valid()) self.assertFalse(serializer.is_valid())
def test_sync_users_mapping_empty(self):
"""Check that when sync_users is enabled, property mappings must be set"""
serializer = LDAPSourceSerializer(
data={
"name": "foo",
"slug": " foo",
"server_uri": "ldaps://1.2.3.4",
"bind_cn": "",
"bind_password": LDAP_PASSWORD,
"base_dn": "dc=foo",
"sync_users": True,
"user_property_mappings": [],
}
)
self.assertFalse(serializer.is_valid())
def test_sync_groups_mapping_empty(self):
"""Check that when sync_groups is enabled, property mappings must be set"""
serializer = LDAPSourceSerializer(
data={
"name": "foo",
"slug": " foo",
"server_uri": "ldaps://1.2.3.4",
"bind_cn": "",
"bind_password": LDAP_PASSWORD,
"base_dn": "dc=foo",
"sync_groups": True,
"group_property_mappings": [],
}
)
self.assertFalse(serializer.is_valid())

View File

@ -15,13 +15,12 @@ from authentik.sources.oauth.models import OAuthSource
from authentik.sources.oauth.types.registry import SourceType, registry from authentik.sources.oauth.types.registry import SourceType, registry
from authentik.sources.oauth.views.callback import OAuthCallback from authentik.sources.oauth.views.callback import OAuthCallback
from authentik.sources.oauth.views.redirect import OAuthRedirect from authentik.sources.oauth.views.redirect import OAuthRedirect
from authentik.stages.identification.stage import LoginChallengeMixin
LOGGER = get_logger() LOGGER = get_logger()
APPLE_CLIENT_ID_PARTS = 3 APPLE_CLIENT_ID_PARTS = 3
class AppleLoginChallenge(LoginChallengeMixin, Challenge): class AppleLoginChallenge(Challenge):
"""Special challenge for apple-native authentication flow, which happens on the client.""" """Special challenge for apple-native authentication flow, which happens on the client."""
client_id = CharField() client_id = CharField()

View File

@ -19,10 +19,9 @@ from authentik.core.models import (
from authentik.core.types import UILoginButton, UserSettingSerializer from authentik.core.types import UILoginButton, UserSettingSerializer
from authentik.flows.challenge import Challenge, ChallengeResponse from authentik.flows.challenge import Challenge, ChallengeResponse
from authentik.lib.generators import generate_id from authentik.lib.generators import generate_id
from authentik.stages.identification.stage import LoginChallengeMixin
class PlexAuthenticationChallenge(LoginChallengeMixin, Challenge): class PlexAuthenticationChallenge(Challenge):
"""Challenge shown to the user in identification stage""" """Challenge shown to the user in identification stage"""
client_id = CharField() client_id = CharField()

View File

@ -1,26 +0,0 @@
# Generated by Django 5.0.9 on 2024-10-10 15:45
from django.db import migrations
from django.apps.registry import Apps
from django.db.backends.base.schema import BaseDatabaseSchemaEditor
def fix_X509SubjectName(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
db_alias = schema_editor.connection.alias
SAMLSource = apps.get_model("authentik_sources_saml", "SAMLSource")
SAMLSource.objects.using(db_alias).filter(
name_id_policy="urn:oasis:names:tc:SAML:2.0:nameid-format:X509SubjectName"
).update(name_id_policy="urn:oasis:names:tc:SAML:1.1:nameid-format:X509SubjectName")
class Migration(migrations.Migration):
dependencies = [
("authentik_sources_saml", "0016_samlsource_encryption_kp"),
]
operations = [
migrations.RunPython(fix_X509SubjectName),
]

View File

@ -19,7 +19,7 @@ NS_MAP = {
SAML_NAME_ID_FORMAT_EMAIL = "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress" SAML_NAME_ID_FORMAT_EMAIL = "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress"
SAML_NAME_ID_FORMAT_PERSISTENT = "urn:oasis:names:tc:SAML:2.0:nameid-format:persistent" SAML_NAME_ID_FORMAT_PERSISTENT = "urn:oasis:names:tc:SAML:2.0:nameid-format:persistent"
SAML_NAME_ID_FORMAT_UNSPECIFIED = "urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified" SAML_NAME_ID_FORMAT_UNSPECIFIED = "urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified"
SAML_NAME_ID_FORMAT_X509 = "urn:oasis:names:tc:SAML:1.1:nameid-format:X509SubjectName" SAML_NAME_ID_FORMAT_X509 = "urn:oasis:names:tc:SAML:2.0:nameid-format:X509SubjectName"
SAML_NAME_ID_FORMAT_WINDOWS = "urn:oasis:names:tc:SAML:2.0:nameid-format:WindowsDomainQualifiedName" SAML_NAME_ID_FORMAT_WINDOWS = "urn:oasis:names:tc:SAML:2.0:nameid-format:WindowsDomainQualifiedName"
SAML_NAME_ID_FORMAT_TRANSIENT = "urn:oasis:names:tc:SAML:2.0:nameid-format:transient" SAML_NAME_ID_FORMAT_TRANSIENT = "urn:oasis:names:tc:SAML:2.0:nameid-format:transient"

View File

@ -1,5 +1,6 @@
"""SAML Service Provider Metadata Processor""" """SAML Service Provider Metadata Processor"""
from collections.abc import Iterator
from typing import Optional from typing import Optional
from django.http import HttpRequest from django.http import HttpRequest
@ -12,6 +13,11 @@ from authentik.sources.saml.processors.constants import (
NS_SAML_METADATA, NS_SAML_METADATA,
NS_SIGNATURE, NS_SIGNATURE,
SAML_BINDING_POST, SAML_BINDING_POST,
SAML_NAME_ID_FORMAT_EMAIL,
SAML_NAME_ID_FORMAT_PERSISTENT,
SAML_NAME_ID_FORMAT_TRANSIENT,
SAML_NAME_ID_FORMAT_WINDOWS,
SAML_NAME_ID_FORMAT_X509,
) )
@ -54,10 +60,19 @@ class MetadataProcessor:
return key_descriptor return key_descriptor
return None return None
def get_name_id_format(self) -> Element: def get_name_id_formats(self) -> Iterator[Element]:
element = Element(f"{{{NS_SAML_METADATA}}}NameIDFormat") """Get compatible NameID Formats"""
element.text = self.source.name_id_policy formats = [
return element SAML_NAME_ID_FORMAT_EMAIL,
SAML_NAME_ID_FORMAT_PERSISTENT,
SAML_NAME_ID_FORMAT_X509,
SAML_NAME_ID_FORMAT_WINDOWS,
SAML_NAME_ID_FORMAT_TRANSIENT,
]
for name_id_format in formats:
element = Element(f"{{{NS_SAML_METADATA}}}NameIDFormat")
element.text = name_id_format
yield element
def build_entity_descriptor(self) -> str: def build_entity_descriptor(self) -> str:
"""Build full EntityDescriptor""" """Build full EntityDescriptor"""
@ -77,7 +92,8 @@ class MetadataProcessor:
if encryption_descriptor is not None: if encryption_descriptor is not None:
sp_sso_descriptor.append(encryption_descriptor) sp_sso_descriptor.append(encryption_descriptor)
sp_sso_descriptor.append(self.get_name_id_format()) for name_id_format in self.get_name_id_formats():
sp_sso_descriptor.append(name_id_format)
assertion_consumer_service = SubElement( assertion_consumer_service = SubElement(
sp_sso_descriptor, f"{{{NS_SAML_METADATA}}}AssertionConsumerService" sp_sso_descriptor, f"{{{NS_SAML_METADATA}}}AssertionConsumerService"

File diff suppressed because one or more lines are too long

View File

@ -96,9 +96,8 @@ class ConsentStageView(ChallengeStageView):
if PLAN_CONTEXT_PENDING_USER in self.executor.plan.context: if PLAN_CONTEXT_PENDING_USER in self.executor.plan.context:
user = self.executor.plan.context[PLAN_CONTEXT_PENDING_USER] user = self.executor.plan.context[PLAN_CONTEXT_PENDING_USER]
# Remove expired consents to prevent database unique constraints errors
consent: UserConsent | None = UserConsent.filter_not_expired( consent: UserConsent | None = UserConsent.filter_not_expired(
delete_expired=True, user=user, application=application user=user, application=application
).first() ).first()
self.executor.plan.context[PLAN_CONTEXT_CONSENT] = consent self.executor.plan.context[PLAN_CONTEXT_CONSENT] = consent

View File

@ -26,31 +26,23 @@ from authentik.flows.models import FlowDesignation
from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER
from authentik.flows.stage import PLAN_CONTEXT_PENDING_USER_IDENTIFIER, ChallengeStageView from authentik.flows.stage import PLAN_CONTEXT_PENDING_USER_IDENTIFIER, ChallengeStageView
from authentik.flows.views.executor import SESSION_KEY_APPLICATION_PRE, SESSION_KEY_GET from authentik.flows.views.executor import SESSION_KEY_APPLICATION_PRE, SESSION_KEY_GET
from authentik.lib.utils.reflection import all_subclasses
from authentik.lib.utils.urls import reverse_with_qs from authentik.lib.utils.urls import reverse_with_qs
from authentik.root.middleware import ClientIPMiddleware from authentik.root.middleware import ClientIPMiddleware
from authentik.sources.oauth.types.apple import AppleLoginChallenge
from authentik.sources.plex.models import PlexAuthenticationChallenge
from authentik.stages.identification.models import IdentificationStage from authentik.stages.identification.models import IdentificationStage
from authentik.stages.identification.signals import identification_failed from authentik.stages.identification.signals import identification_failed
from authentik.stages.password.stage import authenticate from authentik.stages.password.stage import authenticate
class LoginChallengeMixin:
"""Base login challenge for Identification stage"""
def get_login_serializers():
mapping = {
RedirectChallenge().fields["component"].default: RedirectChallenge,
}
for cls in all_subclasses(LoginChallengeMixin):
mapping[cls().fields["component"].default] = cls
return mapping
@extend_schema_field( @extend_schema_field(
PolymorphicProxySerializer( PolymorphicProxySerializer(
component_name="LoginChallengeTypes", component_name="LoginChallengeTypes",
serializers=get_login_serializers, serializers={
RedirectChallenge().fields["component"].default: RedirectChallenge,
PlexAuthenticationChallenge().fields["component"].default: PlexAuthenticationChallenge,
AppleLoginChallenge().fields["component"].default: AppleLoginChallenge,
},
resource_type_field_name="component", resource_type_field_name="component",
) )
) )
@ -104,7 +96,7 @@ class IdentificationChallengeResponse(ChallengeResponse):
if not pre_user: if not pre_user:
with start_span( with start_span(
op="authentik.stages.identification.validate_invalid_wait", op="authentik.stages.identification.validate_invalid_wait",
name="Sleep random time on invalid user identifier", description="Sleep random time on invalid user identifier",
): ):
# Sleep a random time (between 90 and 210ms) to "prevent" user enumeration attacks # Sleep a random time (between 90 and 210ms) to "prevent" user enumeration attacks
sleep(0.030 * SystemRandom().randint(3, 7)) sleep(0.030 * SystemRandom().randint(3, 7))
@ -146,7 +138,7 @@ class IdentificationChallengeResponse(ChallengeResponse):
try: try:
with start_span( with start_span(
op="authentik.stages.identification.authenticate", op="authentik.stages.identification.authenticate",
name="User authenticate call (combo stage)", description="User authenticate call (combo stage)",
): ):
user = authenticate( user = authenticate(
self.stage.request, self.stage.request,

View File

@ -49,7 +49,7 @@ def authenticate(
LOGGER.debug("Attempting authentication...", backend=backend_path) LOGGER.debug("Attempting authentication...", backend=backend_path)
with start_span( with start_span(
op="authentik.stages.password.authenticate", op="authentik.stages.password.authenticate",
name=backend_path, description=backend_path,
): ):
user = backend.authenticate(request, **credentials) user = backend.authenticate(request, **credentials)
if user is None: if user is None:

View File

@ -38,7 +38,7 @@ LOGGER = get_logger()
class FieldTypes(models.TextChoices): class FieldTypes(models.TextChoices):
"""Field types an Prompt can be""" """Field types an Prompt can be"""
# update website/docs/add-secure-apps/flows-stages/stages/prompt/index.md # update website/docs/flow/stages/prompt/index.md
# Simple text field # Simple text field
TEXT = "text", _("Text: Simple Text input") TEXT = "text", _("Text: Simple Text input")

View File

@ -1,9 +1,12 @@
"""Serializer for tenants models""" """Serializer for tenants models"""
from django_tenants.utils import get_public_schema_name from django_tenants.utils import get_public_schema_name
from rest_framework.fields import SerializerMethodField
from rest_framework.generics import RetrieveUpdateAPIView from rest_framework.generics import RetrieveUpdateAPIView
from rest_framework.permissions import SAFE_METHODS from rest_framework.permissions import SAFE_METHODS
from authentik.analytics.api import AnalyticsDescriptionSerializer
from authentik.analytics.utils import get_analytics_description
from authentik.core.api.utils import ModelSerializer from authentik.core.api.utils import ModelSerializer
from authentik.rbac.permissions import HasPermission from authentik.rbac.permissions import HasPermission
from authentik.tenants.models import Tenant from authentik.tenants.models import Tenant
@ -12,6 +15,8 @@ from authentik.tenants.models import Tenant
class SettingsSerializer(ModelSerializer): class SettingsSerializer(ModelSerializer):
"""Settings Serializer""" """Settings Serializer"""
analytics_sources_obj = SerializerMethodField()
class Meta: class Meta:
model = Tenant model = Tenant
fields = [ fields = [
@ -25,8 +30,19 @@ class SettingsSerializer(ModelSerializer):
"impersonation", "impersonation",
"default_token_duration", "default_token_duration",
"default_token_length", "default_token_length",
"default_token_length",
"analytics_enabled",
"analytics_sources",
"analytics_sources_obj",
] ]
def get_analytics_sources_obj(self, obj: Tenant) -> list[AnalyticsDescriptionSerializer]:
result = []
for label, desc in get_analytics_description().items():
if label in obj.analytics_sources:
result.append((label, desc))
return result
class SettingsView(RetrieveUpdateAPIView): class SettingsView(RetrieveUpdateAPIView):
"""Settings view""" """Settings view"""

View File

@ -0,0 +1,26 @@
# Generated by Django 5.0.9 on 2024-09-24 15:36
import django.contrib.postgres.fields
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("authentik_tenants", "0003_alter_tenant_default_token_duration"),
]
operations = [
migrations.AddField(
model_name="tenant",
name="analytics_enabled",
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name="tenant",
name="analytics_sources",
field=django.contrib.postgres.fields.ArrayField(
base_field=models.TextField(), blank=True, default=list, size=None
),
),
]

View File

@ -4,6 +4,7 @@ import re
from uuid import uuid4 from uuid import uuid4
from django.apps import apps from django.apps import apps
from django.contrib.postgres.fields import ArrayField
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.core.validators import MinValueValidator from django.core.validators import MinValueValidator
from django.db import models from django.db import models
@ -96,6 +97,9 @@ class Tenant(TenantMixin, SerializerModel):
validators=[MinValueValidator(1)], validators=[MinValueValidator(1)],
) )
analytics_enabled = models.BooleanField(default=False)
analytics_sources = ArrayField(models.TextField(), blank=True, default=list)
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
if self.schema_name == "template": if self.schema_name == "template":
raise IntegrityError("Cannot create schema named template") raise IntegrityError("Cannot create schema named template")

View File

@ -82,5 +82,3 @@ entries:
order: 10 order: 10
target: !KeyOf default-authentication-flow-password-binding target: !KeyOf default-authentication-flow-password-binding
policy: !KeyOf default-authentication-flow-password-optional policy: !KeyOf default-authentication-flow-password-optional
attrs:
failure_result: true

View File

@ -1,13 +0,0 @@
version: 1
metadata:
name: Default - Provider invalidation flow
entries:
- attrs:
designation: invalidation
name: Logged out of application
title: You've logged out of %(app)s.
authentication: none
identifiers:
slug: default-provider-invalidation-flow
model: authentik_flows.flow
id: flow

View File

@ -2,7 +2,7 @@
"$schema": "http://json-schema.org/draft-07/schema", "$schema": "http://json-schema.org/draft-07/schema",
"$id": "https://goauthentik.io/blueprints/schema.json", "$id": "https://goauthentik.io/blueprints/schema.json",
"type": "object", "type": "object",
"title": "authentik 2024.8.3 Blueprint schema", "title": "authentik 2024.8.2 Blueprint schema",
"required": [ "required": [
"version", "version",
"entries" "entries"
@ -4227,6 +4227,7 @@
"model_updated", "model_updated",
"model_deleted", "model_deleted",
"email_sent", "email_sent",
"analytics_sent",
"update_available", "update_available",
"custom_" "custom_"
], ],
@ -4251,6 +4252,7 @@
null, null,
"authentik.tenants", "authentik.tenants",
"authentik.admin", "authentik.admin",
"authentik.analytics",
"authentik.api", "authentik.api",
"authentik.crypto", "authentik.crypto",
"authentik.flows", "authentik.flows",
@ -5117,12 +5119,6 @@
"title": "Authorization flow", "title": "Authorization flow",
"description": "Flow used when authorizing this provider." "description": "Flow used when authorizing this provider."
}, },
"invalidation_flow": {
"type": "string",
"format": "uuid",
"title": "Invalidation flow",
"description": "Flow used ending the session from a provider."
},
"property_mappings": { "property_mappings": {
"type": "array", "type": "array",
"items": { "items": {
@ -5293,12 +5289,6 @@
"title": "Authorization flow", "title": "Authorization flow",
"description": "Flow used when authorizing this provider." "description": "Flow used when authorizing this provider."
}, },
"invalidation_flow": {
"type": "string",
"format": "uuid",
"title": "Invalidation flow",
"description": "Flow used ending the session from a provider."
},
"property_mappings": { "property_mappings": {
"type": "array", "type": "array",
"items": { "items": {
@ -5440,12 +5430,6 @@
"title": "Authorization flow", "title": "Authorization flow",
"description": "Flow used when authorizing this provider." "description": "Flow used when authorizing this provider."
}, },
"invalidation_flow": {
"type": "string",
"format": "uuid",
"title": "Invalidation flow",
"description": "Flow used ending the session from a provider."
},
"property_mappings": { "property_mappings": {
"type": "array", "type": "array",
"items": { "items": {
@ -5581,12 +5565,6 @@
"title": "Authorization flow", "title": "Authorization flow",
"description": "Flow used when authorizing this provider." "description": "Flow used when authorizing this provider."
}, },
"invalidation_flow": {
"type": "string",
"format": "uuid",
"title": "Invalidation flow",
"description": "Flow used ending the session from a provider."
},
"property_mappings": { "property_mappings": {
"type": "array", "type": "array",
"items": { "items": {
@ -5712,12 +5690,6 @@
"title": "Authorization flow", "title": "Authorization flow",
"description": "Flow used when authorizing this provider." "description": "Flow used when authorizing this provider."
}, },
"invalidation_flow": {
"type": "string",
"format": "uuid",
"title": "Invalidation flow",
"description": "Flow used ending the session from a provider."
},
"property_mappings": { "property_mappings": {
"type": "array", "type": "array",
"items": { "items": {
@ -5956,10 +5928,6 @@
"title": "Url", "title": "Url",
"description": "Base URL to SCIM requests, usually ends in /v2" "description": "Base URL to SCIM requests, usually ends in /v2"
}, },
"verify_certificates": {
"type": "boolean",
"title": "Verify certificates"
},
"token": { "token": {
"type": "string", "type": "string",
"minLength": 1, "minLength": 1,
@ -7601,7 +7569,7 @@
"enum": [ "enum": [
"urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress", "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
"urn:oasis:names:tc:SAML:2.0:nameid-format:persistent", "urn:oasis:names:tc:SAML:2.0:nameid-format:persistent",
"urn:oasis:names:tc:SAML:1.1:nameid-format:X509SubjectName", "urn:oasis:names:tc:SAML:2.0:nameid-format:X509SubjectName",
"urn:oasis:names:tc:SAML:2.0:nameid-format:WindowsDomainQualifiedName", "urn:oasis:names:tc:SAML:2.0:nameid-format:WindowsDomainQualifiedName",
"urn:oasis:names:tc:SAML:2.0:nameid-format:transient" "urn:oasis:names:tc:SAML:2.0:nameid-format:transient"
], ],
@ -12795,12 +12763,6 @@
"title": "Authorization flow", "title": "Authorization flow",
"description": "Flow used when authorizing this provider." "description": "Flow used when authorizing this provider."
}, },
"invalidation_flow": {
"type": "string",
"format": "uuid",
"title": "Invalidation flow",
"description": "Flow used ending the session from a provider."
},
"property_mappings": { "property_mappings": {
"type": "array", "type": "array",
"items": { "items": {
@ -13156,6 +13118,7 @@
"model_updated", "model_updated",
"model_deleted", "model_deleted",
"email_sent", "email_sent",
"analytics_sent",
"update_available", "update_available",
"custom_" "custom_"
], ],
@ -13317,6 +13280,7 @@
"model_updated", "model_updated",
"model_deleted", "model_deleted",
"email_sent", "email_sent",
"analytics_sent",
"update_available", "update_available",
"custom_" "custom_"
], ],

View File

@ -31,7 +31,7 @@ services:
volumes: volumes:
- redis:/data - redis:/data
server: server:
image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2024.8.3} image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2024.8.2}
restart: unless-stopped restart: unless-stopped
command: server command: server
environment: environment:
@ -52,7 +52,7 @@ services:
- postgresql - postgresql
- redis - redis
worker: worker:
image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2024.8.3} image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2024.8.2}
restart: unless-stopped restart: unless-stopped
command: worker command: worker
environment: environment:

4
go.mod
View File

@ -21,7 +21,7 @@ require (
github.com/jellydator/ttlcache/v3 v3.3.0 github.com/jellydator/ttlcache/v3 v3.3.0
github.com/mitchellh/mapstructure v1.5.0 github.com/mitchellh/mapstructure v1.5.0
github.com/nmcclain/asn1-ber v0.0.0-20170104154839-2661553a0484 github.com/nmcclain/asn1-ber v0.0.0-20170104154839-2661553a0484
github.com/pires/go-proxyproto v0.8.0 github.com/pires/go-proxyproto v0.7.0
github.com/prometheus/client_golang v1.20.4 github.com/prometheus/client_golang v1.20.4
github.com/redis/go-redis/v9 v9.6.1 github.com/redis/go-redis/v9 v9.6.1
github.com/sethvargo/go-envconfig v1.1.0 github.com/sethvargo/go-envconfig v1.1.0
@ -29,7 +29,7 @@ require (
github.com/spf13/cobra v1.8.1 github.com/spf13/cobra v1.8.1
github.com/stretchr/testify v1.9.0 github.com/stretchr/testify v1.9.0
github.com/wwt/guac v1.3.2 github.com/wwt/guac v1.3.2
goauthentik.io/api/v3 v3.2024083.5 goauthentik.io/api/v3 v3.2024082.1
golang.org/x/exp v0.0.0-20230210204819-062eb4c674ab golang.org/x/exp v0.0.0-20230210204819-062eb4c674ab
golang.org/x/oauth2 v0.23.0 golang.org/x/oauth2 v0.23.0
golang.org/x/sync v0.8.0 golang.org/x/sync v0.8.0

8
go.sum
View File

@ -233,8 +233,8 @@ github.com/opentracing/opentracing-go v1.2.0 h1:uEJPy/1a5RIPAJ0Ov+OIO8OxWu77jEv+
github.com/opentracing/opentracing-go v1.2.0/go.mod h1:GxEUsuufX4nBwe+T+Wl9TAgYrxe9dPLANfrWvHYVTgc= github.com/opentracing/opentracing-go v1.2.0/go.mod h1:GxEUsuufX4nBwe+T+Wl9TAgYrxe9dPLANfrWvHYVTgc=
github.com/pingcap/errors v0.11.4 h1:lFuQV/oaUMGcD2tqt+01ROSmJs75VG1ToEOkZIZ4nE4= github.com/pingcap/errors v0.11.4 h1:lFuQV/oaUMGcD2tqt+01ROSmJs75VG1ToEOkZIZ4nE4=
github.com/pingcap/errors v0.11.4/go.mod h1:Oi8TUi2kEtXXLMJk9l1cGmz20kV3TaQ0usTwv5KuLY8= github.com/pingcap/errors v0.11.4/go.mod h1:Oi8TUi2kEtXXLMJk9l1cGmz20kV3TaQ0usTwv5KuLY8=
github.com/pires/go-proxyproto v0.8.0 h1:5unRmEAPbHXHuLjDg01CxJWf91cw3lKHc/0xzKpXEe0= github.com/pires/go-proxyproto v0.7.0 h1:IukmRewDQFWC7kfnb66CSomk2q/seBuilHBYFwyq0Hs=
github.com/pires/go-proxyproto v0.8.0/go.mod h1:iknsfgnH8EkjrMeMyvfKByp9TiBZCKZM0jx2xmKqnVY= github.com/pires/go-proxyproto v0.7.0/go.mod h1:Vz/1JPY/OACxWGQNIRY2BeyDmpoaWmEP40O9LbuiFR4=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
@ -299,8 +299,8 @@ go.opentelemetry.io/otel/trace v1.24.0 h1:CsKnnL4dUAr/0llH9FKuc698G04IrpWV0MQA/Y
go.opentelemetry.io/otel/trace v1.24.0/go.mod h1:HPc3Xr/cOApsBI154IU0OI0HJexz+aw5uPdbs3UCjNU= go.opentelemetry.io/otel/trace v1.24.0/go.mod h1:HPc3Xr/cOApsBI154IU0OI0HJexz+aw5uPdbs3UCjNU=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
goauthentik.io/api/v3 v3.2024083.5 h1:qXJ4VRPP8ZBvCFrOH252JhEbURbu4MK5b0KZBGq4z1w= goauthentik.io/api/v3 v3.2024082.1 h1:V/3tq3rGK8Fse6xqnVQ8epzzytjXRI93y+jNHen2zMQ=
goauthentik.io/api/v3 v3.2024083.5/go.mod h1:zz+mEZg8rY/7eEjkMGWJ2DnGqk+zqxuybGCGrR2O4Kw= goauthentik.io/api/v3 v3.2024082.1/go.mod h1:zz+mEZg8rY/7eEjkMGWJ2DnGqk+zqxuybGCGrR2O4Kw=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=

View File

@ -29,4 +29,4 @@ func UserAgent() string {
return fmt.Sprintf("authentik@%s", FullVersion()) return fmt.Sprintf("authentik@%s", FullVersion())
} }
const VERSION = "2024.8.3" const VERSION = "2024.8.2"

View File

@ -8,17 +8,11 @@ import (
) )
func (db *DirectBinder) Unbind(username string, req *bind.Request) (ldap.LDAPResultCode, error) { func (db *DirectBinder) Unbind(username string, req *bind.Request) (ldap.LDAPResultCode, error) {
flowSlug := db.si.GetInvalidationFlowSlug()
if flowSlug == nil {
req.Log().Debug("Provider does not have a logout flow configured")
db.si.SetFlags(req.BindDN, nil)
return ldap.LDAPResultSuccess, nil
}
flags := db.si.GetFlags(req.BindDN) flags := db.si.GetFlags(req.BindDN)
if flags == nil || flags.Session == nil { if flags == nil || flags.Session == nil {
return ldap.LDAPResultSuccess, nil return ldap.LDAPResultSuccess, nil
} }
fe := flow.NewFlowExecutor(req.Context(), *flowSlug, db.si.GetAPIClient().GetConfig(), log.Fields{ fe := flow.NewFlowExecutor(req.Context(), db.si.GetInvalidationFlowSlug(), db.si.GetAPIClient().GetConfig(), log.Fields{
"boundDN": req.BindDN, "boundDN": req.BindDN,
"client": req.RemoteAddr(), "client": req.RemoteAddr(),
"requestId": req.ID(), "requestId": req.ID(),
@ -28,7 +22,7 @@ func (db *DirectBinder) Unbind(username string, req *bind.Request) (ldap.LDAPRes
fe.Params.Add("goauthentik.io/outpost/ldap", "true") fe.Params.Add("goauthentik.io/outpost/ldap", "true")
_, err := fe.Execute() _, err := fe.Execute()
if err != nil { if err != nil {
req.Log().WithError(err).Warning("failed to logout user") db.log.WithError(err).Warning("failed to logout user")
} }
db.si.SetFlags(req.BindDN, nil) db.si.SetFlags(req.BindDN, nil)
return ldap.LDAPResultSuccess, nil return ldap.LDAPResultSuccess, nil

View File

@ -26,7 +26,7 @@ type ProviderInstance struct {
appSlug string appSlug string
authenticationFlowSlug string authenticationFlowSlug string
invalidationFlowSlug *string invalidationFlowSlug string
s *LDAPServer s *LDAPServer
log *log.Entry log *log.Entry
@ -99,7 +99,7 @@ func (pi *ProviderInstance) GetAuthenticationFlowSlug() string {
return pi.authenticationFlowSlug return pi.authenticationFlowSlug
} }
func (pi *ProviderInstance) GetInvalidationFlowSlug() *string { func (pi *ProviderInstance) GetInvalidationFlowSlug() string {
return pi.invalidationFlowSlug return pi.invalidationFlowSlug
} }

View File

@ -29,6 +29,16 @@ func (ls *LDAPServer) getCurrentProvider(pk int32) *ProviderInstance {
return nil return nil
} }
func (ls *LDAPServer) getInvalidationFlow() string {
req, _, err := ls.ac.Client.CoreApi.CoreBrandsCurrentRetrieve(context.Background()).Execute()
if err != nil {
ls.log.WithError(err).Warning("failed to fetch brand config")
return ""
}
flow := req.GetFlowInvalidation()
return flow
}
func (ls *LDAPServer) Refresh() error { func (ls *LDAPServer) Refresh() error {
apiProviders, err := ak.Paginator(ls.ac.Client.OutpostsApi.OutpostsLdapList(context.Background()), ak.PaginatorOptions{ apiProviders, err := ak.Paginator(ls.ac.Client.OutpostsApi.OutpostsLdapList(context.Background()), ak.PaginatorOptions{
PageSize: 100, PageSize: 100,
@ -41,6 +51,7 @@ func (ls *LDAPServer) Refresh() error {
return errors.New("no ldap provider defined") return errors.New("no ldap provider defined")
} }
providers := make([]*ProviderInstance, len(apiProviders)) providers := make([]*ProviderInstance, len(apiProviders))
invalidationFlow := ls.getInvalidationFlow()
for idx, provider := range apiProviders { for idx, provider := range apiProviders {
userDN := strings.ToLower(fmt.Sprintf("ou=%s,%s", constants.OUUsers, *provider.BaseDn)) userDN := strings.ToLower(fmt.Sprintf("ou=%s,%s", constants.OUUsers, *provider.BaseDn))
groupDN := strings.ToLower(fmt.Sprintf("ou=%s,%s", constants.OUGroups, *provider.BaseDn)) groupDN := strings.ToLower(fmt.Sprintf("ou=%s,%s", constants.OUGroups, *provider.BaseDn))
@ -64,7 +75,7 @@ func (ls *LDAPServer) Refresh() error {
UserDN: userDN, UserDN: userDN,
appSlug: provider.ApplicationSlug, appSlug: provider.ApplicationSlug,
authenticationFlowSlug: provider.BindFlowSlug, authenticationFlowSlug: provider.BindFlowSlug,
invalidationFlowSlug: provider.UnbindFlowSlug.Get(), invalidationFlowSlug: invalidationFlow,
boundUsersMutex: usersMutex, boundUsersMutex: usersMutex,
boundUsers: users, boundUsers: users,
s: ls, s: ls,

View File

@ -12,7 +12,7 @@ type LDAPServerInstance interface {
GetOutpostName() string GetOutpostName() string
GetAuthenticationFlowSlug() string GetAuthenticationFlowSlug() string
GetInvalidationFlowSlug() *string GetInvalidationFlowSlug() string
GetAppSlug() string GetAppSlug() string
GetProviderID() int32 GetProviderID() int32

View File

@ -6,7 +6,6 @@ import (
"errors" "errors"
"net" "net"
"net/http" "net/http"
"strings"
"sync" "sync"
sentryhttp "github.com/getsentry/sentry-go/http" sentryhttp "github.com/getsentry/sentry-go/http"
@ -71,20 +70,12 @@ func NewProxyServer(ac *ak.APIController) *ProxyServer {
} }
func (ps *ProxyServer) HandleHost(rw http.ResponseWriter, r *http.Request) bool { func (ps *ProxyServer) HandleHost(rw http.ResponseWriter, r *http.Request) bool {
// Always handle requests for outpost paths that should answer regardless of hostname
if strings.HasPrefix(r.URL.Path, "/outpost.goauthentik.io/ping") ||
strings.HasPrefix(r.URL.Path, "/outpost.goauthentik.io/static") {
ps.mux.ServeHTTP(rw, r)
return true
}
// lookup app by hostname
a, _ := ps.lookupApp(r) a, _ := ps.lookupApp(r)
if a == nil { if a == nil {
return false return false
} }
// check if the app should handle this URL, or is setup in proxy mode
if a.ShouldHandleURL(r) || a.Mode() == api.PROXYMODE_PROXY { if a.ShouldHandleURL(r) || a.Mode() == api.PROXYMODE_PROXY {
ps.mux.ServeHTTP(rw, r) a.ServeHTTP(rw, r)
return true return true
} }
return false return false

View File

@ -8,7 +8,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: PACKAGE VERSION\n" "Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2024-10-12 00:08+0000\n" "POT-Creation-Date: 2024-09-08 00:09+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n" "Language-Team: LANGUAGE <LL@li.org>\n"
@ -36,7 +36,8 @@ msgid "Blueprint file does not exist"
msgstr "" msgstr ""
#: authentik/blueprints/api.py #: authentik/blueprints/api.py
msgid "Failed to validate blueprint" #, python-brace-format
msgid "Failed to validate blueprint: {logs}"
msgstr "" msgstr ""
#: authentik/blueprints/api.py #: authentik/blueprints/api.py
@ -1848,10 +1849,6 @@ msgstr ""
msgid "Used recovery-link to authenticate." msgid "Used recovery-link to authenticate."
msgstr "" msgstr ""
#: authentik/sources/ldap/api.py
msgid "Only a single LDAP Source with password synchronization is allowed"
msgstr ""
#: authentik/sources/ldap/models.py #: authentik/sources/ldap/models.py
msgid "Server URI" msgid "Server URI"
msgstr "" msgstr ""

Some files were not shown because too many files have changed in this diff Show More