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]
current_version = 2024.8.3
current_version = 2024.8.2
tag = True
commit = True
parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)(?:-(?P<rc_t>[a-zA-Z-]+)(?P<rc_n>[1-9]\\d*))?

View File

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

View File

@ -1,7 +1,7 @@
<!--
👋 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

View File

@ -24,11 +24,17 @@ jobs:
- prettier-check
project:
- web
- tests/wdio
include:
- command: tsc
project: web
- command: lit-analyse
project: web
exclude:
- command: lint:lockfile
project: tests/wdio
- command: tsc
project: tests/wdio
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
@ -44,7 +50,15 @@ jobs:
- name: Lint
working-directory: ${{ matrix.project }}/
run: npm run ${{ matrix.command }}
ci-web-mark:
needs:
- lint
runs-on: ubuntu-latest
steps:
- run: echo mark
build:
needs:
- ci-web-mark
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
@ -60,13 +74,6 @@ jobs:
- name: build
working-directory: web/
run: npm run build
ci-web-mark:
needs:
- build
- lint
runs-on: ubuntu-latest
steps:
- run: echo mark
test:
needs:
- 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"
# 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 TARGETVARIANT
@ -124,7 +124,7 @@ RUN --mount=type=bind,target=./pyproject.toml,src=./pyproject.toml \
pip install --force-reinstall /wheels/*"
# 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 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 \
-I .github/codespell-words.txt \
-S 'web/src/locales/**' \
-S 'website/docs/developer-docs/api/reference/**' \
-S 'website/developer-docs/api/reference/**' \
authentik \
internal \
cmd \
web/src \
website/src \
website/blog \
website/developer-docs \
website/docs \
website/integrations \
website/src

View File

@ -34,7 +34,7 @@ For bigger setups, there is a Helm Chart [here](https://github.com/goauthentik/h
## 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

View File

@ -2,7 +2,7 @@
from os import environ
__version__ = "2024.8.3"
__version__ = "2024.8.2"
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 {}
valid, logs = Importer.from_string(content, context).validate()
if not valid:
text_logs = "\n".join([x["event"] for x in logs])
raise ValidationError(
[
_("Failed to validate blueprint"),
*[f"- {x.event}" for x in logs],
]
_("Failed to validate blueprint: {logs}".format_map({"logs": text_logs}))
)
return content

View File

@ -29,7 +29,9 @@ def check_blueprint_v1_file(BlueprintInstance: type, db_alias, path: Path):
if version != 1:
return
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")))
meta = None
if metadata:

View File

@ -78,5 +78,5 @@ class TestBlueprintsV1API(APITestCase):
self.assertEqual(res.status_code, 400)
self.assertJSONEqual(
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
# 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"
@ -429,7 +429,7 @@ class Importer:
orig_import = deepcopy(self._import)
if self._import.version != 1:
self.logger.warning("Invalid blueprint version")
return False, [LogEvent("Invalid blueprint version", log_level="warning", logger=None)]
return False, [{"event": "Invalid blueprint version"}]
with (
transaction_rollback(),
capture_logs() as logs,

View File

@ -38,7 +38,6 @@ class ProviderSerializer(ModelSerializer, MetaNameSerializer):
"name",
"authentication_flow",
"authorization_flow",
"invalidation_flow",
"property_mappings",
"component",
"assigned_application_slug",
@ -51,7 +50,6 @@ class ProviderSerializer(ModelSerializer, MetaNameSerializer):
]
extra_kwargs = {
"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)
return Response(status=401)
user_to_be = self.get_object()
# Check both object-level perms and global perms
if not request.user.has_perm(
"authentik_core.impersonate", user_to_be
) and not request.user.has_perm("authentik_core.impersonate"):
if not request.user.has_perm("impersonate", user_to_be):
LOGGER.debug("User attempted to impersonate without permissions", user=request.user)
return Response(status=401)
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 structlog.stdlib import get_logger
from authentik.analytics.models import AnalyticsMixin
from authentik.blueprints.models import ManagedModel
from authentik.core.expression.exceptions import PropertyMappingExpressionException
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)
class Group(SerializerModel, AttributesMixin):
class Group(SerializerModel, AttributesMixin, AnalyticsMixin):
"""Group model which supports a basic hierarchy and has attributes"""
group_uuid = models.UUIDField(primary_key=True, editable=False, default=uuid4)
@ -258,7 +259,7 @@ class UserManager(DjangoUserManager):
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."""
uuid = models.UUIDField(default=uuid4, editable=False, unique=True)
@ -376,7 +377,7 @@ class User(SerializerModel, GuardianUserMixin, AttributesMixin, AbstractUser):
return get_avatar(self)
class Provider(SerializerModel):
class Provider(SerializerModel, AnalyticsMixin):
"""Application-independent Provider instance. For example SAML2 Remote, OAuth2 Application"""
name = models.TextField(unique=True)
@ -391,23 +392,14 @@ class Provider(SerializerModel):
),
related_name="provider_authentication",
)
authorization_flow = models.ForeignKey(
"authentik_flows.Flow",
# Set to cascade even though null is allowed, since most providers
# still require an authorization flow set
on_delete=models.CASCADE,
null=True,
help_text=_("Flow used when authorizing this provider."),
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)
@ -479,7 +471,7 @@ class ApplicationQuerySet(QuerySet):
return qs
class Application(SerializerModel, PolicyBindingModel):
class Application(SerializerModel, PolicyBindingModel, AnalyticsMixin):
"""Every Application which uses authentik for authentication/identification/authorization
needs an Application record. Other authentication types can subclass this Model to
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"""
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."""
user = models.ForeignKey(User, on_delete=models.CASCADE)
@ -764,7 +756,7 @@ class UserSourceConnection(SerializerModel, CreatedUpdatedModel):
unique_together = (("user", "source"),)
class GroupSourceConnection(SerializerModel, CreatedUpdatedModel):
class GroupSourceConnection(SerializerModel, CreatedUpdatedModel, AnalyticsMixin):
"""Connection between Group and Source."""
group = models.ForeignKey(Group, on_delete=models.CASCADE)
@ -802,25 +794,12 @@ class ExpiringModel(models.Model):
return self.delete(*args, **kwargs)
@classmethod
def _not_expired_filter(cls):
return Q(expires__gt=now(), expiring=True) | Q(expiring=False)
@classmethod
def filter_not_expired(cls, delete_expired=False, **kwargs) -> QuerySet["ExpiringModel"]:
def filter_not_expired(cls, **kwargs) -> QuerySet["Token"]:
"""Filer for tokens which are not expired yet or are not expiring,
and match filters in `kwargs`"""
if delete_expired:
cls.delete_expired(**kwargs)
return cls.objects.filter(cls._not_expired_filter()).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
for obj in cls.objects.filter(**kwargs).filter(Q(expires__lt=now(), expiring=True)):
obj.delete()
return cls.objects.filter(**kwargs)
@property
def is_expired(self) -> bool:
@ -901,7 +880,7 @@ class Token(SerializerModel, ManagedModel, ExpiringModel):
).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."""
pm_uuid = models.UUIDField(primary_key=True, editable=False, default=uuid4)

View File

@ -30,7 +30,12 @@ def clean_expired_models(self: SystemTask):
messages = []
for cls in ExpiringModel.__subclasses__():
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)
messages.append(f"Expired {amount} {cls._meta.verbose_name_plural}")
# 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_slug": "allowed",
"authentication_flow": None,
"invalidation_flow": None,
"authorization_flow": str(self.provider.authorization_flow.pk),
"component": "ak-provider-oauth2-form",
"meta_model_name": "authentik_providers_oauth2.oauth2provider",
@ -187,7 +186,6 @@ class TestApplicationsAPI(APITestCase):
"assigned_application_name": "allowed",
"assigned_application_slug": "allowed",
"authentication_flow": None,
"invalidation_flow": None,
"authorization_flow": str(self.provider.authorization_flow.pk),
"component": "ak-provider-oauth2-form",
"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.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):
"""Test impersonation with scoped permissions"""
new_user = create_test_user()

View File

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

View File

@ -24,6 +24,7 @@ from authentik.core.views.interface import (
InterfaceView,
RootRedirectView,
)
from authentik.core.views.session import EndSessionView
from authentik.flows.views.interface import FlowInterfaceView
from authentik.root.asgi_middleware import SessionMiddleware
from authentik.root.messages.consumer import MessageConsumer
@ -59,6 +60,11 @@ urlpatterns = [
ensure_csrf_cookie(FlowInterfaceView.as_view()),
name="if-flow",
),
path(
"if/session-end/<slug:application_slug>/",
ensure_csrf_cookie(EndSessionView.as_view()),
name="if-session-end",
),
# Fallback for WS
path("ws/outpost/<uuid:pk>/", InterfaceView.as_view(template_name="if/admin.html")),
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,
"authentication_flow": None,
"authorization_flow": None,
"invalidation_flow": None,
"property_mappings": [],
"connection_expiry": "hours=8",
"delete_token_on_disconnect": False,
@ -121,7 +120,6 @@ class TestEndpointsAPI(APITestCase):
"name": self.provider.name,
"authentication_flow": None,
"authorization_flow": None,
"invalidation_flow": None,
"property_mappings": [],
"component": "ak-provider-rac-form",
"assigned_application_slug": self.app.slug,
@ -151,7 +149,6 @@ class TestEndpointsAPI(APITestCase):
"name": self.provider.name,
"authentication_flow": None,
"authorization_flow": None,
"invalidation_flow": None,
"property_mappings": [],
"component": "ak-provider-rac-form",
"assigned_application_slug": self.app.slug,

View File

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

View File

@ -51,7 +51,7 @@ class GeoIPContextProcessor(MMDBContextProcessor):
"""Wrapper for Reader.city"""
with start_span(
op="authentik.events.geo.city",
name=ip_address,
description=ip_address,
):
if not self.configured():
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"
EMAIL_SENT = "email_sent"
ANALYTICS_SENT = "analytics_sent"
UPDATE_AVAILABLE = "update_available"
CUSTOM_PREFIX = "custom_"

View File

@ -110,21 +110,8 @@ class FlowErrorChallenge(Challenge):
class AccessDeniedChallenge(WithUserInfoChallenge):
"""Challenge when a flow's active stage calls `stage_invalid()`."""
component = CharField(default="ak-stage-access-denied")
error_message = CharField(required=False)
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)
component = CharField(default="ak-stage-access-denied")
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):
from guardian.conf import settings as guardian_settings
from guardian.shortcuts import get_anonymous_user
Flow = apps.get_model("authentik_flows", "Flow")
User = apps.get_model("authentik_core", "User")
db_alias = schema_editor.connection.alias
users = (
User.objects.using(db_alias)
.exclude(username="akadmin")
.exclude(username=guardian_settings.ANONYMOUS_USER_NAME)
)
users = User.objects.using(db_alias).exclude(username="akadmin")
try:
users = users.exclude(pk=get_anonymous_user().pk)
except Exception: # nosec
pass
if users.exists():
Flow.objects.using(db_alias).filter(slug="initial-setup").update(
authentication="require_superuser"

View File

@ -107,9 +107,7 @@ class Stage(SerializerModel):
def in_memory_stage(view: type["StageView"], **kwargs) -> Stage:
"""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`."""
"""Creates an in-memory stage instance, based on a `view` as view."""
stage = Stage()
# Because we can't pickle a locally generated 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:
"""Check each of the flows' policies, check policies for each stage with PolicyBinding
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.set_data("flow", self.flow)
span.set_data("request", request)
@ -233,7 +233,7 @@ class FlowPlanner:
with (
start_span(
op="authentik.flow.planner.build_plan",
name=self.flow.slug,
description=self.flow.slug,
) as span,
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 structlog.stdlib import BoundLogger, get_logger
from authentik.core.models import Application, User
from authentik.core.models import User
from authentik.flows.challenge import (
AccessDeniedChallenge,
Challenge,
@ -21,7 +21,6 @@ from authentik.flows.challenge import (
ContextualFlowInfo,
HttpChallengeResponse,
RedirectChallenge,
SessionEndChallenge,
WithUserInfoChallenge,
)
from authentik.flows.exceptions import StageInvalidException
@ -126,7 +125,7 @@ class ChallengeStageView(StageView):
with (
start_span(
op="authentik.flow.stage.challenge_invalid",
name=self.__class__.__name__,
description=self.__class__.__name__,
),
HIST_FLOWS_STAGE_TIME.labels(
stage_type=self.__class__.__name__, method="challenge_invalid"
@ -136,7 +135,7 @@ class ChallengeStageView(StageView):
with (
start_span(
op="authentik.flow.stage.challenge_valid",
name=self.__class__.__name__,
description=self.__class__.__name__,
),
HIST_FLOWS_STAGE_TIME.labels(
stage_type=self.__class__.__name__, method="challenge_valid"
@ -162,7 +161,7 @@ class ChallengeStageView(StageView):
with (
start_span(
op="authentik.flow.stage.get_challenge",
name=self.__class__.__name__,
description=self.__class__.__name__,
),
HIST_FLOWS_STAGE_TIME.labels(
stage_type=self.__class__.__name__, method="get_challenge"
@ -175,7 +174,7 @@ class ChallengeStageView(StageView):
return self.executor.stage_invalid()
with start_span(
op="authentik.flow.stage._get_challenge",
name=self.__class__.__name__,
description=self.__class__.__name__,
):
if not hasattr(challenge, "initial_data"):
challenge.initial_data = {}
@ -231,7 +230,7 @@ class ChallengeStageView(StageView):
return HttpChallengeResponse(challenge_response)
class AccessDeniedStage(ChallengeStageView):
class AccessDeniedChallengeView(ChallengeStageView):
"""Used internally by FlowExecutor's stage_invalid()"""
error_message: str | None
@ -269,31 +268,3 @@ class RedirectStage(ChallengeStageView):
def challenge_valid(self, response: ChallengeResponse) -> HttpResponse:
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,
FlowPlanner,
)
from authentik.flows.stage import AccessDeniedStage, StageView
from authentik.flows.stage import AccessDeniedChallengeView, StageView
from authentik.lib.sentry import SentryIgnoredException
from authentik.lib.utils.errors import exception_to_string
from authentik.lib.utils.reflection import all_subclasses, class_to_path
@ -153,7 +153,7 @@ class FlowExecutorView(APIView):
return plan
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)
get_params = QueryDict(request.GET.get(QS_QUERY, ""))
if QS_KEY_TOKEN in get_params:
@ -273,7 +273,7 @@ class FlowExecutorView(APIView):
with (
start_span(
op="authentik.flow.executor.stage",
name=class_path,
description=class_path,
) as span,
HIST_FLOW_EXECUTION_STAGE_TIME.labels(
method=request.method.upper(),
@ -324,7 +324,7 @@ class FlowExecutorView(APIView):
with (
start_span(
op="authentik.flow.executor.stage",
name=class_path,
description=class_path,
) as span,
HIST_FLOW_EXECUTION_STAGE_TIME.labels(
method=request.method.upper(),
@ -441,7 +441,7 @@ class FlowExecutorView(APIView):
)
return self.restart_flow(keep_context)
self.cancel()
challenge_view = AccessDeniedStage(self, error_message)
challenge_view = AccessDeniedChallengeView(self, error_message)
challenge_view.request = 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
postgresql:
host: localhost

View File

@ -30,11 +30,6 @@ class TestHTTP(TestCase):
request = self.factory.get("/", HTTP_X_FORWARDED_FOR="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):
"""Test faked IP which is overridden by an outpost"""
token = Token.objects.create(
@ -58,17 +53,6 @@ class TestHTTP(TestCase):
},
)
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
self.user.type = UserTypes.INTERNAL_SERVICE_ACCOUNT
self.user.save()

View File

@ -9,7 +9,7 @@ from uuid import uuid4
from dacite.core import from_dict
from django.contrib.auth.models import Permission
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.utils.translation import gettext_lazy as _
from guardian.models import UserObjectPermission
@ -53,7 +53,7 @@ class ServiceConnectionInvalid(SentryIgnoredException):
class OutpostConfig:
"""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_insecure: bool = False
@ -380,14 +380,13 @@ class Outpost(SerializerModel, ManagedModel):
"""Get/create token for auto-generated user"""
managed = f"goauthentik.io/outpost/{self.token_identifier}"
tokens = Token.filter_not_expired(
delete_expired=True,
identifier=self.token_identifier,
intent=TokenIntents.INTENT_API,
managed=managed,
)
token: Token | None = tokens.first()
if token:
return token
if tokens.exists():
return tokens.first()
try:
return Token.objects.create(
user=self.user,
identifier=self.token_identifier,
@ -396,6 +395,11 @@ class Outpost(SerializerModel, ManagedModel):
expiring=False,
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]:
"""Get an iterator of all objects the user needs read access to"""

View File

@ -113,7 +113,7 @@ class PolicyEngine:
with (
start_span(
op="authentik.policy.engine.build",
name=self.__pbm,
description=self.__pbm,
) as span,
HIST_POLICIES_ENGINE_TOTAL_TIME.labels(
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()
bind_flow_slug = CharField(source="authorization_flow.slug")
unbind_flow_slug = SerializerMethodField()
def get_application_slug(self, instance: LDAPProvider) -> str:
"""Prioritise backchannel slug over direct application slug"""
@ -95,16 +94,6 @@ class LDAPOutpostConfigSerializer(ModelSerializer):
return instance.backchannel_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:
model = LDAPProvider
fields = [
@ -112,7 +101,6 @@ class LDAPOutpostConfigSerializer(ModelSerializer):
"name",
"base_dn",
"bind_flow_slug",
"unbind_flow_slug",
"application_slug",
"certificate",
"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()
class Meta:
indexes = [
models.Index(fields=["token", "provider"]),
]
verbose_name = _("OAuth2 Access Token")
verbose_name_plural = _("OAuth2 Access Tokens")
@ -422,9 +419,6 @@ class RefreshToken(SerializerModel, ExpiringModel, BaseGrantModel):
_id_token = models.TextField(verbose_name=_("ID Token"))
class Meta:
indexes = [
models.Index(fields=["token", "provider"]),
]
verbose_name = _("OAuth2 Refresh Token")
verbose_name_plural = _("OAuth2 Refresh Tokens")

View File

@ -29,6 +29,7 @@ class TesOAuth2Introspection(OAuthTestCase):
self.app = Application.objects.create(
name=generate_id(), slug=generate_id(), provider=self.provider
)
self.app.save()
self.user = create_test_admin_user()
self.auth = b64encode(
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):
"""Test introspect (invalid auth)"""
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.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.jwks import JWKSView
from authentik.providers.oauth2.views.provider import ProviderInfoView
@ -45,7 +44,7 @@ urlpatterns = [
),
path(
"<slug:application_slug>/end-session/",
EndSessionView.as_view(),
RedirectView.as_view(pattern_name="authentik_core:if-session-end", query_string=True),
name="end-session",
),
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:
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:
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:
return TokenIntrospectionParams(refresh_token, provider)
LOGGER.debug("Token does not exist", token=raw_token)

View File

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

View File

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

View File

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

View File

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

View File

@ -82,7 +82,7 @@ class TestServiceProviderMetadataParser(TestCase):
def test_simple(self):
"""Test simple metadata without Signing"""
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.issuer, "http://localhost:8080/saml/metadata")
self.assertEqual(provider.sp_binding, SAMLBindings.POST)
@ -95,7 +95,7 @@ class TestServiceProviderMetadataParser(TestCase):
"""Test Metadata with signing cert"""
create_test_cert()
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.issuer, "http://localhost:8080/apps/user_saml/saml/metadata")
self.assertEqual(provider.sp_binding, SAMLBindings.POST)

View File

@ -1,8 +1,8 @@
"""SLO Views"""
from django.http import Http404, HttpRequest
from django.http import HttpRequest
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.views.decorators.clickjacking import xframe_options_sameorigin
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.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.policies.views import PolicyAccessView
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.
Calls get/post handler."""
flow: Flow
def resolve_provider_application(self):
self.application = get_object_or_404(Application, slug=self.kwargs["application_slug"])
self.provider: SAMLProvider = get_object_or_404(
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:
"""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()
if method_response:
return method_response
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,
return redirect(
"authentik_core:if-session-end",
application_slug=self.kwargs["application_slug"],
)
def post(self, request: HttpRequest, application_slug: str) -> HttpResponse:

View File

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

View File

@ -42,7 +42,6 @@ class SCIMClient[TModel: "Model", TConnection: "Model", TSchema: "BaseModel"](
def __init__(self, provider: SCIMProvider):
super().__init__(provider)
self._session = get_http_session()
self._session.verify = provider.verify_certificates
self.provider = provider
# Remove trailing slashes as we assume the URL doesn't have any
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"))
token = models.TextField(help_text=_("Authentication token"))
verify_certificates = models.BooleanField(default=True)
property_mappings_group = models.ManyToManyField(
PropertyMapping,

View File

@ -22,7 +22,7 @@ def create_admin_group(user: User) -> 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"""
_now = now()
token = Token.objects.create(

View File

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

View File

@ -3,7 +3,6 @@
from typing import Any
from django.core.cache import cache
from django.utils.translation import gettext_lazy as _
from drf_spectacular.utils import extend_schema, inline_serializer
from guardian.shortcuts import get_objects_for_user
from rest_framework.decorators import action
@ -40,8 +39,9 @@ class LDAPSourceSerializer(SourceSerializer):
"""Get cached source connectivity"""
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"""
sync_users_password = attrs.get("sync_users_password", True)
if sync_users_password:
sources = LDAPSource.objects.filter(sync_users_password=True)
if self.instance:
@ -49,31 +49,11 @@ class LDAPSourceSerializer(SourceSerializer):
if sources.exists():
raise ValidationError(
{
"sync_users_password": _(
"sync_users_password": (
"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)
class Meta:
@ -186,8 +166,7 @@ class LDAPSourceViewSet(UsedByMixin, ModelViewSet):
for sync_class in SYNC_CLASSES:
class_name = sync_class.name()
all_objects.setdefault(class_name, [])
for page in sync_class(source).get_objects(size_limit=10):
for obj in page:
for obj in sync_class(source).get_objects(size_limit=10):
obj: dict
obj.pop("raw_attributes", None)
obj.pop("raw_dn", None)

View File

@ -26,16 +26,17 @@ def sync_ldap_source_on_save(sender, instance: LDAPSource, **_):
"""Ensure that source is synced on save (if enabled)"""
if not instance.enabled:
return
ldap_connectivity_check.delay(instance.pk)
# 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 source is newly created, this is the first save event
# and the mappings are created with an m2m event
if instance.sync_users and not instance.user_property_mappings.exists():
return
if instance.sync_groups and not instance.group_property_mappings.exists():
if (
not instance.user_property_mappings.exists()
or not instance.group_property_mappings.exists()
):
return
ldap_sync_single.delay(instance.pk)
ldap_connectivity_check.delay(instance.pk)
@receiver(password_validate)

View File

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

View File

@ -50,35 +50,3 @@ class LDAPAPITests(APITestCase):
}
)
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.views.callback import OAuthCallback
from authentik.sources.oauth.views.redirect import OAuthRedirect
from authentik.stages.identification.stage import LoginChallengeMixin
LOGGER = get_logger()
APPLE_CLIENT_ID_PARTS = 3
class AppleLoginChallenge(LoginChallengeMixin, Challenge):
class AppleLoginChallenge(Challenge):
"""Special challenge for apple-native authentication flow, which happens on the client."""
client_id = CharField()

View File

@ -19,10 +19,9 @@ from authentik.core.models import (
from authentik.core.types import UILoginButton, UserSettingSerializer
from authentik.flows.challenge import Challenge, ChallengeResponse
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"""
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_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_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_TRANSIENT = "urn:oasis:names:tc:SAML:2.0:nameid-format:transient"

View File

@ -1,5 +1,6 @@
"""SAML Service Provider Metadata Processor"""
from collections.abc import Iterator
from typing import Optional
from django.http import HttpRequest
@ -12,6 +13,11 @@ from authentik.sources.saml.processors.constants import (
NS_SAML_METADATA,
NS_SIGNATURE,
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 None
def get_name_id_format(self) -> Element:
def get_name_id_formats(self) -> Iterator[Element]:
"""Get compatible NameID Formats"""
formats = [
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 = self.source.name_id_policy
return element
element.text = name_id_format
yield element
def build_entity_descriptor(self) -> str:
"""Build full EntityDescriptor"""
@ -77,7 +92,8 @@ class MetadataProcessor:
if encryption_descriptor is not None:
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(
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:
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(
delete_expired=True, user=user, application=application
user=user, application=application
).first()
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.stage import PLAN_CONTEXT_PENDING_USER_IDENTIFIER, ChallengeStageView
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.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.signals import identification_failed
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(
PolymorphicProxySerializer(
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",
)
)
@ -104,7 +96,7 @@ class IdentificationChallengeResponse(ChallengeResponse):
if not pre_user:
with start_span(
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(0.030 * SystemRandom().randint(3, 7))
@ -146,7 +138,7 @@ class IdentificationChallengeResponse(ChallengeResponse):
try:
with start_span(
op="authentik.stages.identification.authenticate",
name="User authenticate call (combo stage)",
description="User authenticate call (combo stage)",
):
user = authenticate(
self.stage.request,

View File

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

View File

@ -38,7 +38,7 @@ LOGGER = get_logger()
class FieldTypes(models.TextChoices):
"""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
TEXT = "text", _("Text: Simple Text input")

View File

@ -1,9 +1,12 @@
"""Serializer for tenants models"""
from django_tenants.utils import get_public_schema_name
from rest_framework.fields import SerializerMethodField
from rest_framework.generics import RetrieveUpdateAPIView
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.rbac.permissions import HasPermission
from authentik.tenants.models import Tenant
@ -12,6 +15,8 @@ from authentik.tenants.models import Tenant
class SettingsSerializer(ModelSerializer):
"""Settings Serializer"""
analytics_sources_obj = SerializerMethodField()
class Meta:
model = Tenant
fields = [
@ -25,8 +30,19 @@ class SettingsSerializer(ModelSerializer):
"impersonation",
"default_token_duration",
"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):
"""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 django.apps import apps
from django.contrib.postgres.fields import ArrayField
from django.core.exceptions import ValidationError
from django.core.validators import MinValueValidator
from django.db import models
@ -96,6 +97,9 @@ class Tenant(TenantMixin, SerializerModel):
validators=[MinValueValidator(1)],
)
analytics_enabled = models.BooleanField(default=False)
analytics_sources = ArrayField(models.TextField(), blank=True, default=list)
def save(self, *args, **kwargs):
if self.schema_name == "template":
raise IntegrityError("Cannot create schema named template")

View File

@ -82,5 +82,3 @@ entries:
order: 10
target: !KeyOf default-authentication-flow-password-binding
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",
"$id": "https://goauthentik.io/blueprints/schema.json",
"type": "object",
"title": "authentik 2024.8.3 Blueprint schema",
"title": "authentik 2024.8.2 Blueprint schema",
"required": [
"version",
"entries"
@ -4227,6 +4227,7 @@
"model_updated",
"model_deleted",
"email_sent",
"analytics_sent",
"update_available",
"custom_"
],
@ -4251,6 +4252,7 @@
null,
"authentik.tenants",
"authentik.admin",
"authentik.analytics",
"authentik.api",
"authentik.crypto",
"authentik.flows",
@ -5117,12 +5119,6 @@
"title": "Authorization flow",
"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": {
"type": "array",
"items": {
@ -5293,12 +5289,6 @@
"title": "Authorization flow",
"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": {
"type": "array",
"items": {
@ -5440,12 +5430,6 @@
"title": "Authorization flow",
"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": {
"type": "array",
"items": {
@ -5581,12 +5565,6 @@
"title": "Authorization flow",
"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": {
"type": "array",
"items": {
@ -5712,12 +5690,6 @@
"title": "Authorization flow",
"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": {
"type": "array",
"items": {
@ -5956,10 +5928,6 @@
"title": "Url",
"description": "Base URL to SCIM requests, usually ends in /v2"
},
"verify_certificates": {
"type": "boolean",
"title": "Verify certificates"
},
"token": {
"type": "string",
"minLength": 1,
@ -7601,7 +7569,7 @@
"enum": [
"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: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:transient"
],
@ -12795,12 +12763,6 @@
"title": "Authorization flow",
"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": {
"type": "array",
"items": {
@ -13156,6 +13118,7 @@
"model_updated",
"model_deleted",
"email_sent",
"analytics_sent",
"update_available",
"custom_"
],
@ -13317,6 +13280,7 @@
"model_updated",
"model_deleted",
"email_sent",
"analytics_sent",
"update_available",
"custom_"
],

View File

@ -31,7 +31,7 @@ services:
volumes:
- redis:/data
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
command: server
environment:
@ -52,7 +52,7 @@ services:
- postgresql
- redis
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
command: worker
environment:

4
go.mod
View File

@ -21,7 +21,7 @@ require (
github.com/jellydator/ttlcache/v3 v3.3.0
github.com/mitchellh/mapstructure v1.5.0
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/redis/go-redis/v9 v9.6.1
github.com/sethvargo/go-envconfig v1.1.0
@ -29,7 +29,7 @@ require (
github.com/spf13/cobra v1.8.1
github.com/stretchr/testify v1.9.0
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/oauth2 v0.23.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/pingcap/errors v0.11.4 h1:lFuQV/oaUMGcD2tqt+01ROSmJs75VG1ToEOkZIZ4nE4=
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.8.0/go.mod h1:iknsfgnH8EkjrMeMyvfKByp9TiBZCKZM0jx2xmKqnVY=
github.com/pires/go-proxyproto v0.7.0 h1:IukmRewDQFWC7kfnb66CSomk2q/seBuilHBYFwyq0Hs=
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/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
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.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
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.2024083.5/go.mod h1:zz+mEZg8rY/7eEjkMGWJ2DnGqk+zqxuybGCGrR2O4Kw=
goauthentik.io/api/v3 v3.2024082.1 h1:V/3tq3rGK8Fse6xqnVQ8epzzytjXRI93y+jNHen2zMQ=
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-20190510104115-cbcb75029529/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())
}
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) {
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)
if flags == nil || flags.Session == 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,
"client": req.RemoteAddr(),
"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")
_, err := fe.Execute()
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)
return ldap.LDAPResultSuccess, nil

View File

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

View File

@ -29,6 +29,16 @@ func (ls *LDAPServer) getCurrentProvider(pk int32) *ProviderInstance {
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 {
apiProviders, err := ak.Paginator(ls.ac.Client.OutpostsApi.OutpostsLdapList(context.Background()), ak.PaginatorOptions{
PageSize: 100,
@ -41,6 +51,7 @@ func (ls *LDAPServer) Refresh() error {
return errors.New("no ldap provider defined")
}
providers := make([]*ProviderInstance, len(apiProviders))
invalidationFlow := ls.getInvalidationFlow()
for idx, provider := range apiProviders {
userDN := strings.ToLower(fmt.Sprintf("ou=%s,%s", constants.OUUsers, *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,
appSlug: provider.ApplicationSlug,
authenticationFlowSlug: provider.BindFlowSlug,
invalidationFlowSlug: provider.UnbindFlowSlug.Get(),
invalidationFlowSlug: invalidationFlow,
boundUsersMutex: usersMutex,
boundUsers: users,
s: ls,

View File

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

View File

@ -6,7 +6,6 @@ import (
"errors"
"net"
"net/http"
"strings"
"sync"
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 {
// 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)
if a == nil {
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 {
ps.mux.ServeHTTP(rw, r)
a.ServeHTTP(rw, r)
return true
}
return false

View File

@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\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"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@ -36,7 +36,8 @@ msgid "Blueprint file does not exist"
msgstr ""
#: authentik/blueprints/api.py
msgid "Failed to validate blueprint"
#, python-brace-format
msgid "Failed to validate blueprint: {logs}"
msgstr ""
#: authentik/blueprints/api.py
@ -1848,10 +1849,6 @@ msgstr ""
msgid "Used recovery-link to authenticate."
msgstr ""
#: authentik/sources/ldap/api.py
msgid "Only a single LDAP Source with password synchronization is allowed"
msgstr ""
#: authentik/sources/ldap/models.py
msgid "Server URI"
msgstr ""

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