Compare commits
70 Commits
analytics
...
version/20
Author | SHA1 | Date | |
---|---|---|---|
c99a33baee | |||
b17d482e50 | |||
524d46ad7c | |||
f90d6bb3d9 | |||
2340bced63 | |||
0a51e1b696 | |||
13636c0efe | |||
e7f49d97a8 | |||
736240f60d | |||
e8b5e4c127 | |||
81ec98b198 | |||
c46ab19e79 | |||
de9fc5de6b | |||
eab3d9b411 | |||
7cb40d786f | |||
b4fce08bbc | |||
8a2ba1c518 | |||
25b4306693 | |||
1e279950f1 | |||
960429355f | |||
b4f3748353 | |||
91d2445c61 | |||
dd8f809161 | |||
57a31b5dd1 | |||
09125b6236 | |||
832126c6fe | |||
25fe489b34 | |||
18078fd68f | |||
4fa71d995d | |||
22cec64234 | |||
a87cc27366 | |||
ad7ad1fa78 | |||
c70e609e50 | |||
5f08485fff | |||
3a2ed11821 | |||
ee04f39e28 | |||
2c6aa72f3c | |||
bd0afef790 | |||
fc11cc0a1a | |||
fb78303e8f | |||
2ea04440db | |||
96e1636be3 | |||
c546451a73 | |||
61778053b4 | |||
f5580d311d | |||
99d292bce0 | |||
b2801641bc | |||
bfaa1046b2 | |||
95c30400cc | |||
e77480ee1d | |||
905800e535 | |||
fadeaef4c6 | |||
437efda649 | |||
dd75d5f54b | |||
392a2e582e | |||
a1da183721 | |||
feea2df0b1 | |||
b47acd8c76 | |||
6fd87d9ced | |||
acbb065808 | |||
2fb097061d | |||
8962d17e03 | |||
8326e1490c | |||
091e4d3e4c | |||
6ee77edcbb | |||
763e2288bf | |||
9cdb177ca7 | |||
6070508058 | |||
ec13a5d84d | |||
057de82b01 |
@ -1,5 +1,5 @@
|
||||
[bumpversion]
|
||||
current_version = 2024.8.2
|
||||
current_version = 2024.8.5
|
||||
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*))?
|
||||
|
6
.github/dependabot.yml
vendored
6
.github/dependabot.yml
vendored
@ -44,11 +44,9 @@ updates:
|
||||
- "babel-*"
|
||||
eslint:
|
||||
patterns:
|
||||
- "@eslint/*"
|
||||
- "@typescript-eslint/*"
|
||||
- "eslint-*"
|
||||
- "eslint"
|
||||
- "typescript-eslint"
|
||||
- "eslint-*"
|
||||
storybook:
|
||||
patterns:
|
||||
- "@storybook/*"
|
||||
@ -56,12 +54,10 @@ updates:
|
||||
esbuild:
|
||||
patterns:
|
||||
- "@esbuild/*"
|
||||
- "esbuild*"
|
||||
rollup:
|
||||
patterns:
|
||||
- "@rollup/*"
|
||||
- "rollup-*"
|
||||
- "rollup*"
|
||||
swc:
|
||||
patterns:
|
||||
- "@swc/*"
|
||||
|
2
.github/workflows/api-ts-publish.yml
vendored
2
.github/workflows/api-ts-publish.yml
vendored
@ -40,7 +40,7 @@ jobs:
|
||||
run: |
|
||||
export VERSION=`node -e 'console.log(require("../gen-ts-api/package.json").version)'`
|
||||
npm i @goauthentik/api@$VERSION
|
||||
- uses: peter-evans/create-pull-request@v7
|
||||
- uses: peter-evans/create-pull-request@v6
|
||||
id: cpr
|
||||
with:
|
||||
token: ${{ steps.generate_token.outputs.token }}
|
||||
|
18
.github/workflows/ci-main.yml
vendored
18
.github/workflows/ci-main.yml
vendored
@ -120,12 +120,6 @@ jobs:
|
||||
with:
|
||||
flags: unit
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
- if: ${{ !cancelled() }}
|
||||
uses: codecov/test-results-action@v1
|
||||
with:
|
||||
flags: unit
|
||||
file: unittest.xml
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
test-integration:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
@ -144,12 +138,6 @@ jobs:
|
||||
with:
|
||||
flags: integration
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
- if: ${{ !cancelled() }}
|
||||
uses: codecov/test-results-action@v1
|
||||
with:
|
||||
flags: integration
|
||||
file: unittest.xml
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
test-e2e:
|
||||
name: test-e2e (${{ matrix.job.name }})
|
||||
runs-on: ubuntu-latest
|
||||
@ -202,12 +190,6 @@ jobs:
|
||||
with:
|
||||
flags: e2e
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
- if: ${{ !cancelled() }}
|
||||
uses: codecov/test-results-action@v1
|
||||
with:
|
||||
flags: e2e
|
||||
file: unittest.xml
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
ci-core-mark:
|
||||
needs:
|
||||
- lint
|
||||
|
1
.github/workflows/ci-web.yml
vendored
1
.github/workflows/ci-web.yml
vendored
@ -45,6 +45,7 @@ jobs:
|
||||
- working-directory: ${{ matrix.project }}/
|
||||
run: |
|
||||
npm ci
|
||||
${{ matrix.extra_setup }}
|
||||
- name: Generate API
|
||||
run: make gen-client-ts
|
||||
- name: Lint
|
||||
|
@ -24,7 +24,7 @@ jobs:
|
||||
- name: Setup authentik env
|
||||
uses: ./.github/actions/setup
|
||||
- run: poetry run ak update_webauthn_mds
|
||||
- uses: peter-evans/create-pull-request@v7
|
||||
- uses: peter-evans/create-pull-request@v6
|
||||
id: cpr
|
||||
with:
|
||||
token: ${{ steps.generate_token.outputs.token }}
|
||||
|
2
.github/workflows/image-compress.yml
vendored
2
.github/workflows/image-compress.yml
vendored
@ -42,7 +42,7 @@ jobs:
|
||||
with:
|
||||
githubToken: ${{ steps.generate_token.outputs.token }}
|
||||
compressOnly: ${{ github.event_name != 'pull_request' }}
|
||||
- uses: peter-evans/create-pull-request@v7
|
||||
- uses: peter-evans/create-pull-request@v6
|
||||
if: "${{ github.event_name != 'pull_request' && steps.compress.outputs.markdown != '' }}"
|
||||
id: cpr
|
||||
with:
|
||||
|
@ -32,7 +32,7 @@ jobs:
|
||||
poetry run ak compilemessages
|
||||
make web-check-compile
|
||||
- name: Create Pull Request
|
||||
uses: peter-evans/create-pull-request@v7
|
||||
uses: peter-evans/create-pull-request@v6
|
||||
with:
|
||||
token: ${{ steps.generate_token.outputs.token }}
|
||||
branch: extract-compile-backend-translation
|
||||
|
@ -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.6-slim-bookworm-fips-full AS python-deps
|
||||
FROM ghcr.io/goauthentik/fips-python:3.12.5-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.6-slim-bookworm-fips-full AS final-image
|
||||
FROM ghcr.io/goauthentik/fips-python:3.12.5-slim-bookworm-fips-full AS final-image
|
||||
|
||||
ARG VERSION
|
||||
ARG GIT_BUILD_HASH
|
||||
|
@ -20,8 +20,8 @@ Even if the issue is not a CVE, we still greatly appreciate your help in hardeni
|
||||
|
||||
| Version | Supported |
|
||||
| -------- | --------- |
|
||||
| 2024.4.x | ✅ |
|
||||
| 2024.6.x | ✅ |
|
||||
| 2024.8.x | ✅ |
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
|
@ -2,7 +2,7 @@
|
||||
|
||||
from os import environ
|
||||
|
||||
__version__ = "2024.8.2"
|
||||
__version__ = "2024.8.5"
|
||||
ENV_GIT_HASH_KEY = "GIT_BUILD_HASH"
|
||||
|
||||
|
||||
|
@ -1,20 +0,0 @@
|
||||
"""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,
|
||||
}
|
@ -1,8 +1,10 @@
|
||||
"""authentik admin tasks"""
|
||||
|
||||
import re
|
||||
|
||||
from django.core.cache import cache
|
||||
from django.core.validators import URLValidator
|
||||
from django.db import DatabaseError, InternalError, ProgrammingError
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from packaging.version import parse
|
||||
from requests import RequestException
|
||||
from structlog.stdlib import get_logger
|
||||
@ -19,6 +21,8 @@ LOGGER = get_logger()
|
||||
VERSION_NULL = "0.0.0"
|
||||
VERSION_CACHE_KEY = "authentik_latest_version"
|
||||
VERSION_CACHE_TIMEOUT = 8 * 60 * 60 # 8 hours
|
||||
# Chop of the first ^ because we want to search the entire string
|
||||
URL_FINDER = URLValidator.regex.pattern[1:]
|
||||
LOCAL_VERSION = parse(__version__)
|
||||
|
||||
|
||||
@ -74,16 +78,10 @@ def update_latest_version(self: SystemTask):
|
||||
context__new_version=upstream_version,
|
||||
).exists():
|
||||
return
|
||||
Event.new(
|
||||
EventAction.UPDATE_AVAILABLE,
|
||||
message=_(
|
||||
"New version {version} available!".format(
|
||||
version=upstream_version,
|
||||
)
|
||||
),
|
||||
new_version=upstream_version,
|
||||
changelog=data.get("stable", {}).get("changelog_url"),
|
||||
).save()
|
||||
event_dict = {"new_version": upstream_version}
|
||||
if match := re.search(URL_FINDER, data.get("stable", {}).get("changelog", "")):
|
||||
event_dict["message"] = f"Changelog: {match.group()}"
|
||||
Event.new(EventAction.UPDATE_AVAILABLE, **event_dict).save()
|
||||
except (RequestException, IndexError) as exc:
|
||||
cache.set(VERSION_CACHE_KEY, VERSION_NULL, VERSION_CACHE_TIMEOUT)
|
||||
self.set_error(exc)
|
||||
|
@ -17,7 +17,6 @@ RESPONSE_VALID = {
|
||||
"stable": {
|
||||
"version": "99999999.9999999",
|
||||
"changelog": "See https://goauthentik.io/test",
|
||||
"changelog_url": "https://goauthentik.io/test",
|
||||
"reason": "bugfix",
|
||||
},
|
||||
}
|
||||
@ -36,7 +35,7 @@ class TestAdminTasks(TestCase):
|
||||
Event.objects.filter(
|
||||
action=EventAction.UPDATE_AVAILABLE,
|
||||
context__new_version="99999999.9999999",
|
||||
context__message="New version 99999999.9999999 available!",
|
||||
context__message="Changelog: https://goauthentik.io/test",
|
||||
).exists()
|
||||
)
|
||||
# test that a consecutive check doesn't create a duplicate event
|
||||
@ -46,7 +45,7 @@ class TestAdminTasks(TestCase):
|
||||
Event.objects.filter(
|
||||
action=EventAction.UPDATE_AVAILABLE,
|
||||
context__new_version="99999999.9999999",
|
||||
context__message="New version 99999999.9999999 available!",
|
||||
context__message="Changelog: https://goauthentik.io/test",
|
||||
)
|
||||
),
|
||||
1,
|
||||
|
@ -1,54 +0,0 @@
|
||||
"""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),
|
||||
}
|
||||
)
|
@ -1,12 +0,0 @@
|
||||
"""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
|
@ -1,19 +0,0 @@
|
||||
"""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()}
|
@ -1,17 +0,0 @@
|
||||
"""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"},
|
||||
}
|
||||
}
|
@ -1,45 +0,0 @@
|
||||
"""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)
|
@ -1,76 +0,0 @@
|
||||
"""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())
|
@ -1,8 +0,0 @@
|
||||
"""API URLs"""
|
||||
|
||||
from authentik.analytics.api import AnalyticsDataViewSet, AnalyticsDescriptionViewSet
|
||||
|
||||
api_urlpatterns = [
|
||||
("analytics/description", AnalyticsDescriptionViewSet, "analytics-description"),
|
||||
("analytics/data", AnalyticsDataViewSet, "analytics-data"),
|
||||
]
|
@ -1,112 +0,0 @@
|
||||
"""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(),
|
||||
}
|
@ -51,9 +51,11 @@ 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: {logs}".format_map({"logs": text_logs}))
|
||||
[
|
||||
_("Failed to validate blueprint"),
|
||||
*[f"- {x.event}" for x in logs],
|
||||
]
|
||||
)
|
||||
return content
|
||||
|
||||
|
@ -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"]},
|
||||
)
|
||||
|
@ -429,7 +429,7 @@ class Importer:
|
||||
orig_import = deepcopy(self._import)
|
||||
if self._import.version != 1:
|
||||
self.logger.warning("Invalid blueprint version")
|
||||
return False, [{"event": "Invalid blueprint version"}]
|
||||
return False, [LogEvent("Invalid blueprint version", log_level="warning", logger=None)]
|
||||
with (
|
||||
transaction_rollback(),
|
||||
capture_logs() as logs,
|
||||
|
@ -679,7 +679,10 @@ class UserViewSet(UsedByMixin, ModelViewSet):
|
||||
LOGGER.debug("User attempted to impersonate", user=request.user)
|
||||
return Response(status=401)
|
||||
user_to_be = self.get_object()
|
||||
if not request.user.has_perm("impersonate", user_to_be):
|
||||
# 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"):
|
||||
LOGGER.debug("User attempted to impersonate without permissions", user=request.user)
|
||||
return Response(status=401)
|
||||
if user_to_be.pk == self.request.user.pk:
|
||||
|
@ -23,7 +23,6 @@ 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
|
||||
@ -169,7 +168,7 @@ class GroupQuerySet(CTEQuerySet):
|
||||
return cte.join(Group, group_uuid=cte.col.group_uuid).with_cte(cte)
|
||||
|
||||
|
||||
class Group(SerializerModel, AttributesMixin, AnalyticsMixin):
|
||||
class Group(SerializerModel, AttributesMixin):
|
||||
"""Group model which supports a basic hierarchy and has attributes"""
|
||||
|
||||
group_uuid = models.UUIDField(primary_key=True, editable=False, default=uuid4)
|
||||
@ -259,7 +258,7 @@ class UserManager(DjangoUserManager):
|
||||
return self.get_queryset().exclude_anonymous()
|
||||
|
||||
|
||||
class User(SerializerModel, GuardianUserMixin, AttributesMixin, AbstractUser, AnalyticsMixin):
|
||||
class User(SerializerModel, GuardianUserMixin, AttributesMixin, AbstractUser):
|
||||
"""authentik User model, based on django's contrib auth user model."""
|
||||
|
||||
uuid = models.UUIDField(default=uuid4, editable=False, unique=True)
|
||||
@ -377,7 +376,7 @@ class User(SerializerModel, GuardianUserMixin, AttributesMixin, AbstractUser, An
|
||||
return get_avatar(self)
|
||||
|
||||
|
||||
class Provider(SerializerModel, AnalyticsMixin):
|
||||
class Provider(SerializerModel):
|
||||
"""Application-independent Provider instance. For example SAML2 Remote, OAuth2 Application"""
|
||||
|
||||
name = models.TextField(unique=True)
|
||||
@ -471,7 +470,7 @@ class ApplicationQuerySet(QuerySet):
|
||||
return qs
|
||||
|
||||
|
||||
class Application(SerializerModel, PolicyBindingModel, AnalyticsMixin):
|
||||
class Application(SerializerModel, PolicyBindingModel):
|
||||
"""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"""
|
||||
@ -604,7 +603,7 @@ class SourceGroupMatchingModes(models.TextChoices):
|
||||
)
|
||||
|
||||
|
||||
class Source(ManagedModel, SerializerModel, PolicyBindingModel, AnalyticsMixin):
|
||||
class Source(ManagedModel, SerializerModel, PolicyBindingModel):
|
||||
"""Base Authentication source, i.e. an OAuth Provider, SAML Remote or LDAP Server"""
|
||||
|
||||
name = models.TextField(help_text=_("Source's display Name."))
|
||||
@ -736,7 +735,7 @@ class Source(ManagedModel, SerializerModel, PolicyBindingModel, AnalyticsMixin):
|
||||
]
|
||||
|
||||
|
||||
class UserSourceConnection(SerializerModel, CreatedUpdatedModel, AnalyticsMixin):
|
||||
class UserSourceConnection(SerializerModel, CreatedUpdatedModel):
|
||||
"""Connection between User and Source."""
|
||||
|
||||
user = models.ForeignKey(User, on_delete=models.CASCADE)
|
||||
@ -756,7 +755,7 @@ class UserSourceConnection(SerializerModel, CreatedUpdatedModel, AnalyticsMixin)
|
||||
unique_together = (("user", "source"),)
|
||||
|
||||
|
||||
class GroupSourceConnection(SerializerModel, CreatedUpdatedModel, AnalyticsMixin):
|
||||
class GroupSourceConnection(SerializerModel, CreatedUpdatedModel):
|
||||
"""Connection between Group and Source."""
|
||||
|
||||
group = models.ForeignKey(Group, on_delete=models.CASCADE)
|
||||
@ -880,7 +879,7 @@ class Token(SerializerModel, ManagedModel, ExpiringModel):
|
||||
).save()
|
||||
|
||||
|
||||
class PropertyMapping(SerializerModel, ManagedModel, AnalyticsMixin):
|
||||
class PropertyMapping(SerializerModel, ManagedModel):
|
||||
"""User-defined key -> x mapping which can be used by providers to expose extra data."""
|
||||
|
||||
pm_uuid = models.UUIDField(primary_key=True, editable=False, default=uuid4)
|
||||
|
@ -12,7 +12,7 @@ from authentik.core.tests.utils import create_test_admin_user, create_test_flow
|
||||
from authentik.lib.generators import generate_id
|
||||
from authentik.policies.dummy.models import DummyPolicy
|
||||
from authentik.policies.models import PolicyBinding
|
||||
from authentik.providers.oauth2.models import OAuth2Provider
|
||||
from authentik.providers.oauth2.models import OAuth2Provider, RedirectURI, RedirectURIMatchingMode
|
||||
from authentik.providers.proxy.models import ProxyProvider
|
||||
from authentik.providers.saml.models import SAMLProvider
|
||||
|
||||
@ -24,7 +24,7 @@ class TestApplicationsAPI(APITestCase):
|
||||
self.user = create_test_admin_user()
|
||||
self.provider = OAuth2Provider.objects.create(
|
||||
name="test",
|
||||
redirect_uris="http://some-other-domain",
|
||||
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://some-other-domain")],
|
||||
authorization_flow=create_test_flow(),
|
||||
)
|
||||
self.allowed: Application = Application.objects.create(
|
||||
|
@ -44,6 +44,26 @@ 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()
|
||||
|
@ -31,6 +31,7 @@ class TestTransactionalApplicationsAPI(APITestCase):
|
||||
"provider": {
|
||||
"name": uid,
|
||||
"authorization_flow": str(authorization_flow.pk),
|
||||
"redirect_uris": [],
|
||||
},
|
||||
},
|
||||
)
|
||||
@ -56,6 +57,7 @@ class TestTransactionalApplicationsAPI(APITestCase):
|
||||
"provider": {
|
||||
"name": uid,
|
||||
"authorization_flow": "",
|
||||
"redirect_uris": [],
|
||||
},
|
||||
},
|
||||
)
|
||||
|
@ -18,7 +18,7 @@ from authentik.crypto.models import CertificateKeyPair
|
||||
from authentik.crypto.tasks import MANAGED_DISCOVERED, certificate_discovery
|
||||
from authentik.lib.config import CONFIG
|
||||
from authentik.lib.generators import generate_id, generate_key
|
||||
from authentik.providers.oauth2.models import OAuth2Provider
|
||||
from authentik.providers.oauth2.models import OAuth2Provider, RedirectURI, RedirectURIMatchingMode
|
||||
|
||||
|
||||
class TestCrypto(APITestCase):
|
||||
@ -263,7 +263,7 @@ class TestCrypto(APITestCase):
|
||||
client_id="test",
|
||||
client_secret=generate_key(),
|
||||
authorization_flow=create_test_flow(),
|
||||
redirect_uris="http://localhost",
|
||||
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://localhost")],
|
||||
signing_key=keypair,
|
||||
)
|
||||
response = self.client.get(
|
||||
@ -295,7 +295,7 @@ class TestCrypto(APITestCase):
|
||||
client_id="test",
|
||||
client_secret=generate_key(),
|
||||
authorization_flow=create_test_flow(),
|
||||
redirect_uris="http://localhost",
|
||||
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://localhost")],
|
||||
signing_key=keypair,
|
||||
)
|
||||
response = self.client.get(
|
||||
|
@ -1,49 +0,0 @@
|
||||
# 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"),
|
||||
]
|
||||
),
|
||||
),
|
||||
]
|
@ -119,7 +119,6 @@ class EventAction(models.TextChoices):
|
||||
MODEL_DELETED = "model_deleted"
|
||||
EMAIL_SENT = "email_sent"
|
||||
|
||||
ANALYTICS_SENT = "analytics_sent"
|
||||
UPDATE_AVAILABLE = "update_available"
|
||||
|
||||
CUSTOM_PREFIX = "custom_"
|
||||
|
@ -1,19 +1,22 @@
|
||||
"""authentik events signal listener"""
|
||||
|
||||
from importlib import import_module
|
||||
from typing import Any
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.signals import user_logged_in, user_logged_out
|
||||
from django.db.models.signals import post_save, pre_delete
|
||||
from django.dispatch import receiver
|
||||
from django.http import HttpRequest
|
||||
from rest_framework.request import Request
|
||||
|
||||
from authentik.core.models import User
|
||||
from authentik.core.models import AuthenticatedSession, User
|
||||
from authentik.core.signals import login_failed, password_changed
|
||||
from authentik.events.apps import SYSTEM_TASK_STATUS
|
||||
from authentik.events.models import Event, EventAction, SystemTask
|
||||
from authentik.events.tasks import event_notification_handler, gdpr_cleanup
|
||||
from authentik.flows.models import Stage
|
||||
from authentik.flows.planner import PLAN_CONTEXT_OUTPOST, PLAN_CONTEXT_SOURCE, FlowPlan
|
||||
from authentik.flows.planner import PLAN_CONTEXT_SOURCE, FlowPlan
|
||||
from authentik.flows.views.executor import SESSION_KEY_PLAN
|
||||
from authentik.root.monitoring import monitoring_set
|
||||
from authentik.stages.invitation.models import Invitation
|
||||
@ -23,6 +26,7 @@ from authentik.stages.user_write.signals import user_write
|
||||
from authentik.tenants.utils import get_current_tenant
|
||||
|
||||
SESSION_LOGIN_EVENT = "login_event"
|
||||
_session_engine = import_module(settings.SESSION_ENGINE)
|
||||
|
||||
|
||||
@receiver(user_logged_in)
|
||||
@ -38,16 +42,22 @@ def on_user_logged_in(sender, request: HttpRequest, user: User, **_):
|
||||
# Save the login method used
|
||||
kwargs[PLAN_CONTEXT_METHOD] = flow_plan.context[PLAN_CONTEXT_METHOD]
|
||||
kwargs[PLAN_CONTEXT_METHOD_ARGS] = flow_plan.context.get(PLAN_CONTEXT_METHOD_ARGS, {})
|
||||
if PLAN_CONTEXT_OUTPOST in flow_plan.context:
|
||||
# Save outpost context
|
||||
kwargs[PLAN_CONTEXT_OUTPOST] = flow_plan.context[PLAN_CONTEXT_OUTPOST]
|
||||
event = Event.new(EventAction.LOGIN, **kwargs).from_http(request, user=user)
|
||||
request.session[SESSION_LOGIN_EVENT] = event
|
||||
request.session.save()
|
||||
|
||||
|
||||
def get_login_event(request: HttpRequest) -> Event | None:
|
||||
def get_login_event(request_or_session: HttpRequest | AuthenticatedSession | None) -> Event | None:
|
||||
"""Wrapper to get login event that can be mocked in tests"""
|
||||
return request.session.get(SESSION_LOGIN_EVENT, None)
|
||||
session = None
|
||||
if not request_or_session:
|
||||
return None
|
||||
if isinstance(request_or_session, HttpRequest | Request):
|
||||
session = request_or_session.session
|
||||
if isinstance(request_or_session, AuthenticatedSession):
|
||||
SessionStore = _session_engine.SessionStore
|
||||
session = SessionStore(request_or_session.session_key)
|
||||
return session.get(SESSION_LOGIN_EVENT, None)
|
||||
|
||||
|
||||
@receiver(user_logged_out)
|
||||
|
@ -23,7 +23,6 @@ from authentik.flows.models import (
|
||||
in_memory_stage,
|
||||
)
|
||||
from authentik.lib.config import CONFIG
|
||||
from authentik.outposts.models import Outpost
|
||||
from authentik.policies.engine import PolicyEngine
|
||||
from authentik.root.middleware import ClientIPMiddleware
|
||||
|
||||
@ -33,7 +32,6 @@ PLAN_CONTEXT_SSO = "is_sso"
|
||||
PLAN_CONTEXT_REDIRECT = "redirect"
|
||||
PLAN_CONTEXT_APPLICATION = "application"
|
||||
PLAN_CONTEXT_SOURCE = "source"
|
||||
PLAN_CONTEXT_OUTPOST = "outpost"
|
||||
# Is set by the Flow Planner when a FlowToken was used, and the currently active flow plan
|
||||
# was restored.
|
||||
PLAN_CONTEXT_IS_RESTORED = "is_restored"
|
||||
@ -145,23 +143,10 @@ class FlowPlanner:
|
||||
and not request.user.is_superuser
|
||||
):
|
||||
raise FlowNonApplicableException()
|
||||
outpost_user = ClientIPMiddleware.get_outpost_user(request)
|
||||
if self.flow.authentication == FlowAuthenticationRequirement.REQUIRE_OUTPOST:
|
||||
outpost_user = ClientIPMiddleware.get_outpost_user(request)
|
||||
if not outpost_user:
|
||||
raise FlowNonApplicableException()
|
||||
if outpost_user:
|
||||
outpost = Outpost.objects.filter(
|
||||
# TODO: Since Outpost and user are not directly connected, we have to look up a user
|
||||
# like this. This should ideally by in authentik/outposts/models.py
|
||||
pk=outpost_user.username.replace("ak-outpost-", "")
|
||||
).first()
|
||||
if outpost:
|
||||
return {
|
||||
PLAN_CONTEXT_OUTPOST: {
|
||||
"instance": outpost,
|
||||
}
|
||||
}
|
||||
return {}
|
||||
|
||||
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
|
||||
@ -174,12 +159,11 @@ class FlowPlanner:
|
||||
self._logger.debug(
|
||||
"f(plan): starting planning process",
|
||||
)
|
||||
context = default_context or {}
|
||||
# Bit of a workaround here, if there is a pending user set in the default context
|
||||
# we use that user for our cache key
|
||||
# to make sure they don't get the generic response
|
||||
if context and PLAN_CONTEXT_PENDING_USER in context:
|
||||
user = context[PLAN_CONTEXT_PENDING_USER]
|
||||
if default_context and PLAN_CONTEXT_PENDING_USER in default_context:
|
||||
user = default_context[PLAN_CONTEXT_PENDING_USER]
|
||||
else:
|
||||
user = request.user
|
||||
# We only need to check the flow authentication if it's planned without a user
|
||||
@ -187,13 +171,14 @@ class FlowPlanner:
|
||||
# or if a flow is restarted due to `invalid_response_action` being set to
|
||||
# `restart_with_context`, which can only happen if the user was already authorized
|
||||
# to use the flow
|
||||
context.update(self._check_authentication(request))
|
||||
self._check_authentication(request)
|
||||
# First off, check the flow's direct policy bindings
|
||||
# to make sure the user even has access to the flow
|
||||
engine = PolicyEngine(self.flow, user, request)
|
||||
engine.use_cache = self.use_cache
|
||||
span.set_data("context", cleanse_dict(context))
|
||||
engine.request.context.update(context)
|
||||
if default_context:
|
||||
span.set_data("default_context", cleanse_dict(default_context))
|
||||
engine.request.context.update(default_context)
|
||||
engine.build()
|
||||
result = engine.result
|
||||
if not result.passing:
|
||||
@ -210,12 +195,12 @@ class FlowPlanner:
|
||||
key=cached_plan_key,
|
||||
)
|
||||
# Reset the context as this isn't factored into caching
|
||||
cached_plan.context = context
|
||||
cached_plan.context = default_context or {}
|
||||
return cached_plan
|
||||
self._logger.debug(
|
||||
"f(plan): building plan",
|
||||
)
|
||||
plan = self._build_plan(user, request, context)
|
||||
plan = self._build_plan(user, request, default_context)
|
||||
if self.use_cache:
|
||||
cache.set(cache_key(self.flow, user), plan, CACHE_TIMEOUT)
|
||||
if not plan.bindings and not self.allow_empty_flows:
|
||||
|
@ -30,6 +30,11 @@ 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(
|
||||
@ -53,6 +58,17 @@ 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()
|
||||
|
@ -21,7 +21,14 @@ class DebugSession(Session):
|
||||
|
||||
def send(self, req: PreparedRequest, *args, **kwargs):
|
||||
request_id = str(uuid4())
|
||||
LOGGER.debug("HTTP request sent", uid=request_id, path=req.path_url, headers=req.headers)
|
||||
LOGGER.debug(
|
||||
"HTTP request sent",
|
||||
uid=request_id,
|
||||
url=req.url,
|
||||
method=req.method,
|
||||
headers=req.headers,
|
||||
body=req.body,
|
||||
)
|
||||
resp = super().send(req, *args, **kwargs)
|
||||
LOGGER.debug(
|
||||
"HTTP response received",
|
||||
|
@ -1,52 +0,0 @@
|
||||
# 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,
|
||||
),
|
||||
),
|
||||
]
|
@ -108,7 +108,7 @@ class EventMatcherPolicy(Policy):
|
||||
result=result,
|
||||
)
|
||||
matches.append(result)
|
||||
passing = any(x.passing for x in matches)
|
||||
passing = all(x.passing for x in matches)
|
||||
messages = chain(*[x.messages for x in matches])
|
||||
result = PolicyResult(passing, *messages)
|
||||
result.source_results = matches
|
||||
|
@ -77,11 +77,24 @@ class TestEventMatcherPolicy(TestCase):
|
||||
request = PolicyRequest(get_anonymous_user())
|
||||
request.context["event"] = event
|
||||
policy: EventMatcherPolicy = EventMatcherPolicy.objects.create(
|
||||
client_ip="1.2.3.5", app="bar"
|
||||
client_ip="1.2.3.5", app="foo"
|
||||
)
|
||||
response = policy.passes(request)
|
||||
self.assertFalse(response.passing)
|
||||
|
||||
def test_multiple(self):
|
||||
"""Test multiple"""
|
||||
event = Event.new(EventAction.LOGIN)
|
||||
event.app = "foo"
|
||||
event.client_ip = "1.2.3.4"
|
||||
request = PolicyRequest(get_anonymous_user())
|
||||
request.context["event"] = event
|
||||
policy: EventMatcherPolicy = EventMatcherPolicy.objects.create(
|
||||
client_ip="1.2.3.4", app="foo"
|
||||
)
|
||||
response = policy.passes(request)
|
||||
self.assertTrue(response.passing)
|
||||
|
||||
def test_invalid(self):
|
||||
"""Test passing event"""
|
||||
request = PolicyRequest(get_anonymous_user())
|
||||
|
@ -1,15 +1,18 @@
|
||||
"""OAuth2Provider API Views"""
|
||||
|
||||
from copy import copy
|
||||
from re import compile
|
||||
from re import error as RegexError
|
||||
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from drf_spectacular.types import OpenApiTypes
|
||||
from drf_spectacular.utils import OpenApiParameter, OpenApiResponse, extend_schema
|
||||
from guardian.shortcuts import get_objects_for_user
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.exceptions import ValidationError
|
||||
from rest_framework.fields import CharField
|
||||
from rest_framework.fields import CharField, ChoiceField
|
||||
from rest_framework.generics import get_object_or_404
|
||||
from rest_framework.request import Request
|
||||
from rest_framework.response import Response
|
||||
@ -20,13 +23,39 @@ from authentik.core.api.used_by import UsedByMixin
|
||||
from authentik.core.api.utils import PassiveSerializer, PropertyMappingPreviewSerializer
|
||||
from authentik.core.models import Provider
|
||||
from authentik.providers.oauth2.id_token import IDToken
|
||||
from authentik.providers.oauth2.models import AccessToken, OAuth2Provider, ScopeMapping
|
||||
from authentik.providers.oauth2.models import (
|
||||
AccessToken,
|
||||
OAuth2Provider,
|
||||
RedirectURIMatchingMode,
|
||||
ScopeMapping,
|
||||
)
|
||||
from authentik.rbac.decorators import permission_required
|
||||
|
||||
|
||||
class RedirectURISerializer(PassiveSerializer):
|
||||
"""A single allowed redirect URI entry"""
|
||||
|
||||
matching_mode = ChoiceField(choices=RedirectURIMatchingMode.choices)
|
||||
url = CharField()
|
||||
|
||||
|
||||
class OAuth2ProviderSerializer(ProviderSerializer):
|
||||
"""OAuth2Provider Serializer"""
|
||||
|
||||
redirect_uris = RedirectURISerializer(many=True, source="_redirect_uris")
|
||||
|
||||
def validate_redirect_uris(self, data: list) -> list:
|
||||
for entry in data:
|
||||
if entry.get("matching_mode") == RedirectURIMatchingMode.REGEX:
|
||||
url = entry.get("url")
|
||||
try:
|
||||
compile(url)
|
||||
except RegexError:
|
||||
raise ValidationError(
|
||||
_("Invalid Regex Pattern: {url}".format(url=url))
|
||||
) from None
|
||||
return data
|
||||
|
||||
class Meta:
|
||||
model = OAuth2Provider
|
||||
fields = ProviderSerializer.Meta.fields + [
|
||||
@ -78,7 +107,6 @@ class OAuth2ProviderViewSet(UsedByMixin, ModelViewSet):
|
||||
"refresh_token_validity",
|
||||
"include_claims_in_id_token",
|
||||
"signing_key",
|
||||
"redirect_uris",
|
||||
"sub_mode",
|
||||
"property_mappings",
|
||||
"issuer_mode",
|
||||
|
@ -7,7 +7,7 @@ from django.http import HttpRequest, HttpResponse, HttpResponseRedirect
|
||||
from authentik.events.models import Event, EventAction
|
||||
from authentik.lib.sentry import SentryIgnoredException
|
||||
from authentik.lib.views import bad_request_message
|
||||
from authentik.providers.oauth2.models import GrantTypes
|
||||
from authentik.providers.oauth2.models import GrantTypes, RedirectURI
|
||||
|
||||
|
||||
class OAuth2Error(SentryIgnoredException):
|
||||
@ -46,9 +46,9 @@ class RedirectUriError(OAuth2Error):
|
||||
)
|
||||
|
||||
provided_uri: str
|
||||
allowed_uris: list[str]
|
||||
allowed_uris: list[RedirectURI]
|
||||
|
||||
def __init__(self, provided_uri: str, allowed_uris: list[str]) -> None:
|
||||
def __init__(self, provided_uri: str, allowed_uris: list[RedirectURI]) -> None:
|
||||
super().__init__()
|
||||
self.provided_uri = provided_uri
|
||||
self.allowed_uris = allowed_uris
|
||||
|
@ -1,6 +1,7 @@
|
||||
"""id_token utils"""
|
||||
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from hashlib import sha256
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from django.db import models
|
||||
@ -23,8 +24,13 @@ if TYPE_CHECKING:
|
||||
from authentik.providers.oauth2.models import BaseGrantModel, OAuth2Provider
|
||||
|
||||
|
||||
def hash_session_key(session_key: str) -> str:
|
||||
"""Hash the session key for inclusion in JWTs as `sid`"""
|
||||
return sha256(session_key.encode("ascii")).hexdigest()
|
||||
|
||||
|
||||
class SubModes(models.TextChoices):
|
||||
"""Mode after which 'sub' attribute is generateed, for compatibility reasons"""
|
||||
"""Mode after which 'sub' attribute is generated, for compatibility reasons"""
|
||||
|
||||
HASHED_USER_ID = "hashed_user_id", _("Based on the Hashed User ID")
|
||||
USER_ID = "user_id", _("Based on user ID")
|
||||
@ -51,7 +57,8 @@ class IDToken:
|
||||
and potentially other requested Claims. The ID Token is represented as a
|
||||
JSON Web Token (JWT) [JWT].
|
||||
|
||||
https://openid.net/specs/openid-connect-core-1_0.html#IDToken"""
|
||||
https://openid.net/specs/openid-connect-core-1_0.html#IDToken
|
||||
https://www.iana.org/assignments/jwt/jwt.xhtml"""
|
||||
|
||||
# Issuer, https://www.rfc-editor.org/rfc/rfc7519.html#section-4.1.1
|
||||
iss: str | None = None
|
||||
@ -79,6 +86,8 @@ class IDToken:
|
||||
nonce: str | None = None
|
||||
# Access Token hash value, http://openid.net/specs/openid-connect-core-1_0.html
|
||||
at_hash: str | None = None
|
||||
# Session ID, https://openid.net/specs/openid-connect-frontchannel-1_0.html#ClaimsContents
|
||||
sid: str | None = None
|
||||
|
||||
claims: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
@ -116,9 +125,11 @@ class IDToken:
|
||||
now = timezone.now()
|
||||
id_token.iat = int(now.timestamp())
|
||||
id_token.auth_time = int(token.auth_time.timestamp())
|
||||
if token.session:
|
||||
id_token.sid = hash_session_key(token.session.session_key)
|
||||
|
||||
# We use the timestamp of the user's last successful login (EventAction.LOGIN) for auth_time
|
||||
auth_event = get_login_event(request)
|
||||
auth_event = get_login_event(token.session)
|
||||
if auth_event:
|
||||
# Also check which method was used for authentication
|
||||
method = auth_event.context.get(PLAN_CONTEXT_METHOD, "")
|
||||
|
@ -3,6 +3,7 @@
|
||||
import django.db.models.deletion
|
||||
from django.apps.registry import Apps
|
||||
from django.db import migrations, models
|
||||
from django.db.backends.base.schema import BaseDatabaseSchemaEditor
|
||||
|
||||
import authentik.lib.utils.time
|
||||
|
||||
@ -14,7 +15,7 @@ scope_uid_map = {
|
||||
}
|
||||
|
||||
|
||||
def set_managed_flag(apps: Apps, schema_editor):
|
||||
def set_managed_flag(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
|
||||
ScopeMapping = apps.get_model("authentik_providers_oauth2", "ScopeMapping")
|
||||
db_alias = schema_editor.connection.alias
|
||||
for mapping in ScopeMapping.objects.using(db_alias).filter(name__startswith="Autogenerated "):
|
||||
|
@ -0,0 +1,26 @@
|
||||
# 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),
|
||||
]
|
||||
|
||||
# Original preserved
|
||||
# See https://github.com/goauthentik/authentik/issues/11874
|
||||
# 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"),
|
||||
# ),
|
||||
# ]
|
||||
operations = []
|
@ -0,0 +1,34 @@
|
||||
# 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),
|
||||
]
|
||||
|
||||
# Original preserved
|
||||
# See https://github.com/goauthentik/authentik/issues/11874
|
||||
# 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"),
|
||||
# ),
|
||||
# ]
|
||||
operations = []
|
@ -0,0 +1,42 @@
|
||||
# Generated by Django 5.0.9 on 2024-10-16 14:53
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("authentik_crypto", "0004_alter_certificatekeypair_name"),
|
||||
(
|
||||
"authentik_providers_oauth2",
|
||||
"0020_remove_accesstoken_authentik_p_token_4bc870_idx_and_more",
|
||||
),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="oauth2provider",
|
||||
name="encryption_key",
|
||||
field=models.ForeignKey(
|
||||
help_text="Key used to encrypt the tokens. When set, tokens will be encrypted and returned as JWEs.",
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="oauth2provider_encryption_key_set",
|
||||
to="authentik_crypto.certificatekeypair",
|
||||
verbose_name="Encryption Key",
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="oauth2provider",
|
||||
name="signing_key",
|
||||
field=models.ForeignKey(
|
||||
help_text="Key used to sign the tokens.",
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="oauth2provider_signing_key_set",
|
||||
to="authentik_crypto.certificatekeypair",
|
||||
verbose_name="Signing Key",
|
||||
),
|
||||
),
|
||||
]
|
@ -0,0 +1,113 @@
|
||||
# Generated by Django 5.0.9 on 2024-10-23 13:38
|
||||
|
||||
from hashlib import sha256
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
from django.apps.registry import Apps
|
||||
from django.db.backends.base.schema import BaseDatabaseSchemaEditor
|
||||
from authentik.lib.migrations import progress_bar
|
||||
|
||||
|
||||
def migrate_session(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
|
||||
AuthenticatedSession = apps.get_model("authentik_core", "authenticatedsession")
|
||||
AuthorizationCode = apps.get_model("authentik_providers_oauth2", "authorizationcode")
|
||||
AccessToken = apps.get_model("authentik_providers_oauth2", "accesstoken")
|
||||
RefreshToken = apps.get_model("authentik_providers_oauth2", "refreshtoken")
|
||||
db_alias = schema_editor.connection.alias
|
||||
|
||||
print(f"\nFetching session keys, this might take a couple of minutes...")
|
||||
session_ids = {}
|
||||
for session in progress_bar(AuthenticatedSession.objects.using(db_alias).all()):
|
||||
session_ids[sha256(session.session_key.encode("ascii")).hexdigest()] = session.session_key
|
||||
for model in [AuthorizationCode, AccessToken, RefreshToken]:
|
||||
print(
|
||||
f"\nAdding session to {model._meta.verbose_name}, this might take a couple of minutes..."
|
||||
)
|
||||
for code in progress_bar(model.objects.using(db_alias).all()):
|
||||
if code.session_id_old not in session_ids:
|
||||
continue
|
||||
code.session = (
|
||||
AuthenticatedSession.objects.using(db_alias)
|
||||
.filter(session_key=session_ids[code.session_id_old])
|
||||
.first()
|
||||
)
|
||||
code.save()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("authentik_core", "0039_source_group_matching_mode_alter_group_name_and_more"),
|
||||
("authentik_providers_oauth2", "0021_oauth2provider_encryption_key_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RenameField(
|
||||
model_name="accesstoken",
|
||||
old_name="session_id",
|
||||
new_name="session_id_old",
|
||||
),
|
||||
migrations.RenameField(
|
||||
model_name="authorizationcode",
|
||||
old_name="session_id",
|
||||
new_name="session_id_old",
|
||||
),
|
||||
migrations.RenameField(
|
||||
model_name="refreshtoken",
|
||||
old_name="session_id",
|
||||
new_name="session_id_old",
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="accesstoken",
|
||||
name="session",
|
||||
field=models.ForeignKey(
|
||||
default=None,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_DEFAULT,
|
||||
to="authentik_core.authenticatedsession",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="authorizationcode",
|
||||
name="session",
|
||||
field=models.ForeignKey(
|
||||
default=None,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_DEFAULT,
|
||||
to="authentik_core.authenticatedsession",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="devicetoken",
|
||||
name="session",
|
||||
field=models.ForeignKey(
|
||||
default=None,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_DEFAULT,
|
||||
to="authentik_core.authenticatedsession",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="refreshtoken",
|
||||
name="session",
|
||||
field=models.ForeignKey(
|
||||
default=None,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_DEFAULT,
|
||||
to="authentik_core.authenticatedsession",
|
||||
),
|
||||
),
|
||||
migrations.RunPython(migrate_session),
|
||||
migrations.RemoveField(
|
||||
model_name="accesstoken",
|
||||
name="session_id_old",
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name="authorizationcode",
|
||||
name="session_id_old",
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name="refreshtoken",
|
||||
name="session_id_old",
|
||||
),
|
||||
]
|
@ -0,0 +1,31 @@
|
||||
# Generated by Django 5.0.9 on 2024-10-31 14:28
|
||||
|
||||
import django.contrib.postgres.indexes
|
||||
from django.conf import settings
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("authentik_core", "0039_source_group_matching_mode_alter_group_name_and_more"),
|
||||
("authentik_providers_oauth2", "0022_remove_accesstoken_session_id_and_more"),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunSQL("DROP INDEX IF EXISTS authentik_p_token_f99422_idx;"),
|
||||
migrations.RunSQL("DROP INDEX IF EXISTS authentik_p_token_a1d921_idx;"),
|
||||
migrations.AddIndex(
|
||||
model_name="accesstoken",
|
||||
index=django.contrib.postgres.indexes.HashIndex(
|
||||
fields=["token"], name="authentik_p_token_e00883_hash"
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="refreshtoken",
|
||||
index=django.contrib.postgres.indexes.HashIndex(
|
||||
fields=["token"], name="authentik_p_token_32e2b7_hash"
|
||||
),
|
||||
),
|
||||
]
|
@ -0,0 +1,48 @@
|
||||
# Generated by Django 5.0.9 on 2024-11-04 12:56
|
||||
from django.apps.registry import Apps
|
||||
|
||||
from django.db.backends.base.schema import BaseDatabaseSchemaEditor
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
def migrate_redirect_uris(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
|
||||
from authentik.providers.oauth2.models import RedirectURI, RedirectURIMatchingMode
|
||||
|
||||
OAuth2Provider = apps.get_model("authentik_providers_oauth2", "oauth2provider")
|
||||
|
||||
db_alias = schema_editor.connection.alias
|
||||
for provider in OAuth2Provider.objects.using(db_alias).all():
|
||||
uris = []
|
||||
for old in provider.old_redirect_uris.split("\n"):
|
||||
mode = RedirectURIMatchingMode.STRICT
|
||||
if old == "*" or old == ".*":
|
||||
mode = RedirectURIMatchingMode.REGEX
|
||||
uris.append(RedirectURI(mode, url=old))
|
||||
provider.redirect_uris = uris
|
||||
provider.save()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("authentik_providers_oauth2", "0023_alter_accesstoken_refreshtoken_use_hash_index"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RenameField(
|
||||
model_name="oauth2provider",
|
||||
old_name="redirect_uris",
|
||||
new_name="old_redirect_uris",
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="oauth2provider",
|
||||
name="_redirect_uris",
|
||||
field=models.JSONField(default=dict, verbose_name="Redirect URIs"),
|
||||
),
|
||||
migrations.RunPython(migrate_redirect_uris, lambda *args: ...),
|
||||
migrations.RemoveField(
|
||||
model_name="oauth2provider",
|
||||
name="old_redirect_uris",
|
||||
),
|
||||
]
|
@ -3,7 +3,7 @@
|
||||
import base64
|
||||
import binascii
|
||||
import json
|
||||
from dataclasses import asdict
|
||||
from dataclasses import asdict, dataclass
|
||||
from functools import cached_property
|
||||
from hashlib import sha256
|
||||
from typing import Any
|
||||
@ -12,6 +12,7 @@ from urllib.parse import urlparse, urlunparse
|
||||
from cryptography.hazmat.primitives.asymmetric.ec import EllipticCurvePrivateKey
|
||||
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey
|
||||
from cryptography.hazmat.primitives.asymmetric.types import PrivateKeyTypes
|
||||
from dacite import Config
|
||||
from dacite.core import from_dict
|
||||
from django.db import models
|
||||
from django.http import HttpRequest
|
||||
@ -23,7 +24,13 @@ from rest_framework.serializers import Serializer
|
||||
from structlog.stdlib import get_logger
|
||||
|
||||
from authentik.brands.models import WebfingerProvider
|
||||
from authentik.core.models import ExpiringModel, PropertyMapping, Provider, User
|
||||
from authentik.core.models import (
|
||||
AuthenticatedSession,
|
||||
ExpiringModel,
|
||||
PropertyMapping,
|
||||
Provider,
|
||||
User,
|
||||
)
|
||||
from authentik.crypto.models import CertificateKeyPair
|
||||
from authentik.lib.generators import generate_code_fixed_length, generate_id, generate_key
|
||||
from authentik.lib.models import SerializerModel
|
||||
@ -67,11 +74,25 @@ class IssuerMode(models.TextChoices):
|
||||
"""Configure how the `iss` field is created."""
|
||||
|
||||
GLOBAL = "global", _("Same identifier is used for all providers")
|
||||
PER_PROVIDER = "per_provider", _(
|
||||
"Each provider has a different issuer, based on the application slug."
|
||||
PER_PROVIDER = (
|
||||
"per_provider",
|
||||
_("Each provider has a different issuer, based on the application slug."),
|
||||
)
|
||||
|
||||
|
||||
class RedirectURIMatchingMode(models.TextChoices):
|
||||
STRICT = "strict", _("Strict URL comparison")
|
||||
REGEX = "regex", _("Regular Expression URL matching")
|
||||
|
||||
|
||||
@dataclass
|
||||
class RedirectURI:
|
||||
"""A single redirect URI entry"""
|
||||
|
||||
matching_mode: RedirectURIMatchingMode
|
||||
url: str
|
||||
|
||||
|
||||
class ResponseTypes(models.TextChoices):
|
||||
"""Response Type required by the client."""
|
||||
|
||||
@ -146,11 +167,9 @@ class OAuth2Provider(WebfingerProvider, Provider):
|
||||
verbose_name=_("Client Secret"),
|
||||
default=generate_client_secret,
|
||||
)
|
||||
redirect_uris = models.TextField(
|
||||
default="",
|
||||
blank=True,
|
||||
_redirect_uris = models.JSONField(
|
||||
default=dict,
|
||||
verbose_name=_("Redirect URIs"),
|
||||
help_text=_("Enter each URI on a new line."),
|
||||
)
|
||||
|
||||
include_claims_in_id_token = models.BooleanField(
|
||||
@ -251,12 +270,33 @@ class OAuth2Provider(WebfingerProvider, Provider):
|
||||
except Provider.application.RelatedObjectDoesNotExist:
|
||||
return None
|
||||
|
||||
@property
|
||||
def redirect_uris(self) -> list[RedirectURI]:
|
||||
uris = []
|
||||
for entry in self._redirect_uris:
|
||||
uris.append(
|
||||
from_dict(
|
||||
RedirectURI,
|
||||
entry,
|
||||
config=Config(type_hooks={RedirectURIMatchingMode: RedirectURIMatchingMode}),
|
||||
)
|
||||
)
|
||||
return uris
|
||||
|
||||
@redirect_uris.setter
|
||||
def redirect_uris(self, value: list[RedirectURI]):
|
||||
cleansed = []
|
||||
for entry in value:
|
||||
cleansed.append(asdict(entry))
|
||||
self._redirect_uris = cleansed
|
||||
|
||||
@property
|
||||
def launch_url(self) -> str | None:
|
||||
"""Guess launch_url based on first redirect_uri"""
|
||||
if self.redirect_uris == "":
|
||||
redirects = self.redirect_uris
|
||||
if len(redirects) < 1:
|
||||
return None
|
||||
main_url = self.redirect_uris.split("\n", maxsplit=1)[0]
|
||||
main_url = redirects[0].url
|
||||
try:
|
||||
launch_url = urlparse(main_url)._replace(path="")
|
||||
return urlunparse(launch_url)
|
||||
@ -320,7 +360,9 @@ class BaseGrantModel(models.Model):
|
||||
revoked = models.BooleanField(default=False)
|
||||
_scope = models.TextField(default="", verbose_name=_("Scopes"))
|
||||
auth_time = models.DateTimeField(verbose_name="Authentication time")
|
||||
session_id = models.CharField(default="", blank=True)
|
||||
session = models.ForeignKey(
|
||||
AuthenticatedSession, null=True, on_delete=models.SET_DEFAULT, default=None
|
||||
)
|
||||
|
||||
class Meta:
|
||||
abstract = True
|
||||
@ -452,6 +494,9 @@ class DeviceToken(ExpiringModel):
|
||||
device_code = models.TextField(default=generate_key)
|
||||
user_code = models.TextField(default=generate_code_fixed_length)
|
||||
_scope = models.TextField(default="", verbose_name=_("Scopes"))
|
||||
session = models.ForeignKey(
|
||||
AuthenticatedSession, null=True, on_delete=models.SET_DEFAULT, default=None
|
||||
)
|
||||
|
||||
@property
|
||||
def scope(self) -> list[str]:
|
||||
|
@ -1,5 +1,3 @@
|
||||
from hashlib import sha256
|
||||
|
||||
from django.contrib.auth.signals import user_logged_out
|
||||
from django.dispatch import receiver
|
||||
from django.http import HttpRequest
|
||||
@ -13,5 +11,4 @@ def user_logged_out_oauth_access_token(sender, request: HttpRequest, user: User,
|
||||
"""Revoke access tokens upon user logout"""
|
||||
if not request.session or not request.session.session_key:
|
||||
return
|
||||
hashed_session_key = sha256(request.session.session_key.encode("ascii")).hexdigest()
|
||||
AccessToken.objects.filter(user=user, session_id=hashed_session_key).delete()
|
||||
AccessToken.objects.filter(user=user, session__session_key=request.session.session_key).delete()
|
||||
|
@ -10,7 +10,13 @@ from rest_framework.test import APITestCase
|
||||
from authentik.blueprints.tests import apply_blueprint
|
||||
from authentik.core.models import Application
|
||||
from authentik.core.tests.utils import create_test_admin_user, create_test_flow
|
||||
from authentik.providers.oauth2.models import OAuth2Provider, ScopeMapping
|
||||
from authentik.lib.generators import generate_id
|
||||
from authentik.providers.oauth2.models import (
|
||||
OAuth2Provider,
|
||||
RedirectURI,
|
||||
RedirectURIMatchingMode,
|
||||
ScopeMapping,
|
||||
)
|
||||
|
||||
|
||||
class TestAPI(APITestCase):
|
||||
@ -21,7 +27,7 @@ class TestAPI(APITestCase):
|
||||
self.provider: OAuth2Provider = OAuth2Provider.objects.create(
|
||||
name="test",
|
||||
authorization_flow=create_test_flow(),
|
||||
redirect_uris="http://testserver",
|
||||
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://testserver")],
|
||||
)
|
||||
self.provider.property_mappings.set(ScopeMapping.objects.all())
|
||||
self.app = Application.objects.create(name="test", slug="test", provider=self.provider)
|
||||
@ -50,9 +56,29 @@ class TestAPI(APITestCase):
|
||||
@skipUnless(version_info >= (3, 11, 4), "This behaviour is only Python 3.11.4 and up")
|
||||
def test_launch_url(self):
|
||||
"""Test launch_url"""
|
||||
self.provider.redirect_uris = (
|
||||
"https://[\\d\\w]+.pr.test.goauthentik.io/source/oauth/callback/authentik/\n"
|
||||
)
|
||||
self.provider.redirect_uris = [
|
||||
RedirectURI(
|
||||
RedirectURIMatchingMode.REGEX,
|
||||
"https://[\\d\\w]+.pr.test.goauthentik.io/source/oauth/callback/authentik/",
|
||||
),
|
||||
]
|
||||
self.provider.save()
|
||||
self.provider.refresh_from_db()
|
||||
self.assertIsNone(self.provider.launch_url)
|
||||
|
||||
def test_validate_redirect_uris(self):
|
||||
"""Test redirect_uris API"""
|
||||
response = self.client.post(
|
||||
reverse("authentik_api:oauth2provider-list"),
|
||||
data={
|
||||
"name": generate_id(),
|
||||
"authorization_flow": create_test_flow().pk,
|
||||
"invalidation_flow": create_test_flow().pk,
|
||||
"redirect_uris": [
|
||||
{"matching_mode": "strict", "url": "http://goauthentik.io"},
|
||||
{"matching_mode": "regex", "url": "**"},
|
||||
],
|
||||
},
|
||||
)
|
||||
self.assertJSONEqual(response.content, {"redirect_uris": ["Invalid Regex Pattern: **"]})
|
||||
self.assertEqual(response.status_code, 400)
|
||||
|
@ -19,6 +19,8 @@ from authentik.providers.oauth2.models import (
|
||||
AuthorizationCode,
|
||||
GrantTypes,
|
||||
OAuth2Provider,
|
||||
RedirectURI,
|
||||
RedirectURIMatchingMode,
|
||||
ScopeMapping,
|
||||
)
|
||||
from authentik.providers.oauth2.tests.utils import OAuthTestCase
|
||||
@ -39,7 +41,7 @@ class TestAuthorize(OAuthTestCase):
|
||||
name=generate_id(),
|
||||
client_id="test",
|
||||
authorization_flow=create_test_flow(),
|
||||
redirect_uris="http://local.invalid/Foo",
|
||||
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://local.invalid/Foo")],
|
||||
)
|
||||
with self.assertRaises(AuthorizeError):
|
||||
request = self.factory.get(
|
||||
@ -64,7 +66,7 @@ class TestAuthorize(OAuthTestCase):
|
||||
name=generate_id(),
|
||||
client_id="test",
|
||||
authorization_flow=create_test_flow(),
|
||||
redirect_uris="http://local.invalid/Foo",
|
||||
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://local.invalid/Foo")],
|
||||
)
|
||||
with self.assertRaises(AuthorizeError):
|
||||
request = self.factory.get(
|
||||
@ -84,7 +86,7 @@ class TestAuthorize(OAuthTestCase):
|
||||
name=generate_id(),
|
||||
client_id="test",
|
||||
authorization_flow=create_test_flow(),
|
||||
redirect_uris="http://local.invalid",
|
||||
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://local.invalid")],
|
||||
)
|
||||
with self.assertRaises(RedirectUriError):
|
||||
request = self.factory.get("/", data={"response_type": "code", "client_id": "test"})
|
||||
@ -106,7 +108,7 @@ class TestAuthorize(OAuthTestCase):
|
||||
name=generate_id(),
|
||||
client_id="test",
|
||||
authorization_flow=create_test_flow(),
|
||||
redirect_uris="data:local.invalid",
|
||||
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "data:local.invalid")],
|
||||
)
|
||||
with self.assertRaises(RedirectUriError):
|
||||
request = self.factory.get(
|
||||
@ -125,7 +127,7 @@ class TestAuthorize(OAuthTestCase):
|
||||
name=generate_id(),
|
||||
client_id="test",
|
||||
authorization_flow=create_test_flow(),
|
||||
redirect_uris="",
|
||||
redirect_uris=[],
|
||||
)
|
||||
with self.assertRaises(RedirectUriError):
|
||||
request = self.factory.get("/", data={"response_type": "code", "client_id": "test"})
|
||||
@ -140,7 +142,7 @@ class TestAuthorize(OAuthTestCase):
|
||||
)
|
||||
OAuthAuthorizationParams.from_request(request)
|
||||
provider.refresh_from_db()
|
||||
self.assertEqual(provider.redirect_uris, "+")
|
||||
self.assertEqual(provider.redirect_uris, [RedirectURI(RedirectURIMatchingMode.STRICT, "+")])
|
||||
|
||||
def test_invalid_redirect_uri_regex(self):
|
||||
"""test missing/invalid redirect URI"""
|
||||
@ -148,7 +150,7 @@ class TestAuthorize(OAuthTestCase):
|
||||
name=generate_id(),
|
||||
client_id="test",
|
||||
authorization_flow=create_test_flow(),
|
||||
redirect_uris="http://local.invalid?",
|
||||
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://local.invalid?")],
|
||||
)
|
||||
with self.assertRaises(RedirectUriError):
|
||||
request = self.factory.get("/", data={"response_type": "code", "client_id": "test"})
|
||||
@ -170,7 +172,7 @@ class TestAuthorize(OAuthTestCase):
|
||||
name=generate_id(),
|
||||
client_id="test",
|
||||
authorization_flow=create_test_flow(),
|
||||
redirect_uris="+",
|
||||
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "+")],
|
||||
)
|
||||
with self.assertRaises(RedirectUriError):
|
||||
request = self.factory.get("/", data={"response_type": "code", "client_id": "test"})
|
||||
@ -213,7 +215,7 @@ class TestAuthorize(OAuthTestCase):
|
||||
name=generate_id(),
|
||||
client_id="test",
|
||||
authorization_flow=create_test_flow(),
|
||||
redirect_uris="http://local.invalid/Foo",
|
||||
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://local.invalid/Foo")],
|
||||
)
|
||||
provider.property_mappings.set(
|
||||
ScopeMapping.objects.filter(
|
||||
@ -301,7 +303,7 @@ class TestAuthorize(OAuthTestCase):
|
||||
name=generate_id(),
|
||||
client_id="test",
|
||||
authorization_flow=flow,
|
||||
redirect_uris="foo://localhost",
|
||||
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "foo://localhost")],
|
||||
access_code_validity="seconds=100",
|
||||
)
|
||||
Application.objects.create(name="app", slug="app", provider=provider)
|
||||
@ -343,7 +345,7 @@ class TestAuthorize(OAuthTestCase):
|
||||
name=generate_id(),
|
||||
client_id="test",
|
||||
authorization_flow=flow,
|
||||
redirect_uris="http://localhost",
|
||||
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://localhost")],
|
||||
signing_key=self.keypair,
|
||||
)
|
||||
provider.property_mappings.set(
|
||||
@ -419,7 +421,7 @@ class TestAuthorize(OAuthTestCase):
|
||||
name=generate_id(),
|
||||
client_id="test",
|
||||
authorization_flow=flow,
|
||||
redirect_uris="http://localhost",
|
||||
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://localhost")],
|
||||
signing_key=self.keypair,
|
||||
)
|
||||
Application.objects.create(name="app", slug="app", provider=provider)
|
||||
@ -474,7 +476,7 @@ class TestAuthorize(OAuthTestCase):
|
||||
name=generate_id(),
|
||||
client_id=generate_id(),
|
||||
authorization_flow=flow,
|
||||
redirect_uris="http://localhost",
|
||||
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://localhost")],
|
||||
signing_key=self.keypair,
|
||||
)
|
||||
provider.property_mappings.set(
|
||||
@ -532,7 +534,7 @@ class TestAuthorize(OAuthTestCase):
|
||||
name=generate_id(),
|
||||
client_id=generate_id(),
|
||||
authorization_flow=flow,
|
||||
redirect_uris="http://localhost",
|
||||
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://localhost")],
|
||||
signing_key=self.keypair,
|
||||
)
|
||||
app = Application.objects.create(name=generate_id(), slug=generate_id(), provider=provider)
|
||||
|
@ -11,7 +11,14 @@ from authentik.core.models import Application
|
||||
from authentik.core.tests.utils import create_test_admin_user, create_test_cert, create_test_flow
|
||||
from authentik.lib.generators import generate_id
|
||||
from authentik.providers.oauth2.constants import ACR_AUTHENTIK_DEFAULT
|
||||
from authentik.providers.oauth2.models import AccessToken, IDToken, OAuth2Provider, RefreshToken
|
||||
from authentik.providers.oauth2.models import (
|
||||
AccessToken,
|
||||
IDToken,
|
||||
OAuth2Provider,
|
||||
RedirectURI,
|
||||
RedirectURIMatchingMode,
|
||||
RefreshToken,
|
||||
)
|
||||
from authentik.providers.oauth2.tests.utils import OAuthTestCase
|
||||
|
||||
|
||||
@ -23,13 +30,12 @@ class TesOAuth2Introspection(OAuthTestCase):
|
||||
self.provider: OAuth2Provider = OAuth2Provider.objects.create(
|
||||
name=generate_id(),
|
||||
authorization_flow=create_test_flow(),
|
||||
redirect_uris="",
|
||||
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "")],
|
||||
signing_key=create_test_cert(),
|
||||
)
|
||||
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()
|
||||
@ -114,6 +120,41 @@ 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=[RedirectURI(RedirectURIMatchingMode.STRICT, "")],
|
||||
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(
|
||||
|
@ -13,7 +13,7 @@ from authentik.core.tests.utils import create_test_cert, create_test_flow
|
||||
from authentik.crypto.builder import PrivateKeyAlg
|
||||
from authentik.crypto.models import CertificateKeyPair
|
||||
from authentik.lib.generators import generate_id
|
||||
from authentik.providers.oauth2.models import OAuth2Provider
|
||||
from authentik.providers.oauth2.models import OAuth2Provider, RedirectURI, RedirectURIMatchingMode
|
||||
from authentik.providers.oauth2.tests.utils import OAuthTestCase
|
||||
|
||||
TEST_CORDS_CERT = """
|
||||
@ -49,7 +49,7 @@ class TestJWKS(OAuthTestCase):
|
||||
name="test",
|
||||
client_id="test",
|
||||
authorization_flow=create_test_flow(),
|
||||
redirect_uris="http://local.invalid",
|
||||
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://local.invalid")],
|
||||
signing_key=create_test_cert(),
|
||||
)
|
||||
app = Application.objects.create(name="test", slug="test", provider=provider)
|
||||
@ -68,7 +68,7 @@ class TestJWKS(OAuthTestCase):
|
||||
name="test",
|
||||
client_id="test",
|
||||
authorization_flow=create_test_flow(),
|
||||
redirect_uris="http://local.invalid",
|
||||
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://local.invalid")],
|
||||
)
|
||||
app = Application.objects.create(name="test", slug="test", provider=provider)
|
||||
response = self.client.get(
|
||||
@ -82,7 +82,7 @@ class TestJWKS(OAuthTestCase):
|
||||
name="test",
|
||||
client_id="test",
|
||||
authorization_flow=create_test_flow(),
|
||||
redirect_uris="http://local.invalid",
|
||||
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://local.invalid")],
|
||||
signing_key=create_test_cert(PrivateKeyAlg.ECDSA),
|
||||
)
|
||||
app = Application.objects.create(name="test", slug="test", provider=provider)
|
||||
@ -104,7 +104,7 @@ class TestJWKS(OAuthTestCase):
|
||||
name="test",
|
||||
client_id="test",
|
||||
authorization_flow=create_test_flow(),
|
||||
redirect_uris="http://local.invalid",
|
||||
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://local.invalid")],
|
||||
signing_key=cert,
|
||||
)
|
||||
app = Application.objects.create(name="test", slug="test", provider=provider)
|
||||
|
@ -10,7 +10,14 @@ from django.utils import timezone
|
||||
from authentik.core.models import Application
|
||||
from authentik.core.tests.utils import create_test_admin_user, create_test_cert, create_test_flow
|
||||
from authentik.lib.generators import generate_id
|
||||
from authentik.providers.oauth2.models import AccessToken, IDToken, OAuth2Provider, RefreshToken
|
||||
from authentik.providers.oauth2.models import (
|
||||
AccessToken,
|
||||
IDToken,
|
||||
OAuth2Provider,
|
||||
RedirectURI,
|
||||
RedirectURIMatchingMode,
|
||||
RefreshToken,
|
||||
)
|
||||
from authentik.providers.oauth2.tests.utils import OAuthTestCase
|
||||
|
||||
|
||||
@ -22,7 +29,7 @@ class TesOAuth2Revoke(OAuthTestCase):
|
||||
self.provider: OAuth2Provider = OAuth2Provider.objects.create(
|
||||
name=generate_id(),
|
||||
authorization_flow=create_test_flow(),
|
||||
redirect_uris="",
|
||||
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "")],
|
||||
signing_key=create_test_cert(),
|
||||
)
|
||||
self.app = Application.objects.create(
|
||||
|
@ -22,6 +22,8 @@ from authentik.providers.oauth2.models import (
|
||||
AccessToken,
|
||||
AuthorizationCode,
|
||||
OAuth2Provider,
|
||||
RedirectURI,
|
||||
RedirectURIMatchingMode,
|
||||
RefreshToken,
|
||||
ScopeMapping,
|
||||
)
|
||||
@ -42,7 +44,7 @@ class TestToken(OAuthTestCase):
|
||||
provider = OAuth2Provider.objects.create(
|
||||
name=generate_id(),
|
||||
authorization_flow=create_test_flow(),
|
||||
redirect_uris="http://TestServer",
|
||||
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://TestServer")],
|
||||
signing_key=self.keypair,
|
||||
)
|
||||
header = b64encode(f"{provider.client_id}:{provider.client_secret}".encode()).decode()
|
||||
@ -69,7 +71,7 @@ class TestToken(OAuthTestCase):
|
||||
provider = OAuth2Provider.objects.create(
|
||||
name=generate_id(),
|
||||
authorization_flow=create_test_flow(),
|
||||
redirect_uris="http://testserver",
|
||||
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://testserver")],
|
||||
signing_key=self.keypair,
|
||||
)
|
||||
header = b64encode(f"{provider.client_id}:{provider.client_secret}".encode()).decode()
|
||||
@ -90,7 +92,7 @@ class TestToken(OAuthTestCase):
|
||||
provider = OAuth2Provider.objects.create(
|
||||
name=generate_id(),
|
||||
authorization_flow=create_test_flow(),
|
||||
redirect_uris="http://local.invalid",
|
||||
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://local.invalid")],
|
||||
signing_key=self.keypair,
|
||||
)
|
||||
header = b64encode(f"{provider.client_id}:{provider.client_secret}".encode()).decode()
|
||||
@ -118,7 +120,7 @@ class TestToken(OAuthTestCase):
|
||||
provider = OAuth2Provider.objects.create(
|
||||
name=generate_id(),
|
||||
authorization_flow=create_test_flow(),
|
||||
redirect_uris="http://local.invalid",
|
||||
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://local.invalid")],
|
||||
signing_key=self.keypair,
|
||||
)
|
||||
# Needs to be assigned to an application for iss to be set
|
||||
@ -158,7 +160,7 @@ class TestToken(OAuthTestCase):
|
||||
provider = OAuth2Provider.objects.create(
|
||||
name=generate_id(),
|
||||
authorization_flow=create_test_flow(),
|
||||
redirect_uris="http://local.invalid",
|
||||
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://local.invalid")],
|
||||
signing_key=self.keypair,
|
||||
)
|
||||
provider.property_mappings.set(
|
||||
@ -220,7 +222,7 @@ class TestToken(OAuthTestCase):
|
||||
provider = OAuth2Provider.objects.create(
|
||||
name=generate_id(),
|
||||
authorization_flow=create_test_flow(),
|
||||
redirect_uris="http://local.invalid",
|
||||
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://local.invalid")],
|
||||
signing_key=self.keypair,
|
||||
)
|
||||
provider.property_mappings.set(
|
||||
@ -278,7 +280,7 @@ class TestToken(OAuthTestCase):
|
||||
provider = OAuth2Provider.objects.create(
|
||||
name=generate_id(),
|
||||
authorization_flow=create_test_flow(),
|
||||
redirect_uris="http://testserver",
|
||||
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://testserver")],
|
||||
signing_key=self.keypair,
|
||||
)
|
||||
provider.property_mappings.set(
|
||||
|
@ -19,7 +19,12 @@ from authentik.providers.oauth2.constants import (
|
||||
SCOPE_OPENID_PROFILE,
|
||||
TOKEN_TYPE,
|
||||
)
|
||||
from authentik.providers.oauth2.models import OAuth2Provider, ScopeMapping
|
||||
from authentik.providers.oauth2.models import (
|
||||
OAuth2Provider,
|
||||
RedirectURI,
|
||||
RedirectURIMatchingMode,
|
||||
ScopeMapping,
|
||||
)
|
||||
from authentik.providers.oauth2.tests.utils import OAuthTestCase
|
||||
from authentik.providers.oauth2.views.jwks import JWKSView
|
||||
from authentik.sources.oauth.models import OAuthSource
|
||||
@ -54,7 +59,7 @@ class TestTokenClientCredentialsJWTSource(OAuthTestCase):
|
||||
self.provider: OAuth2Provider = OAuth2Provider.objects.create(
|
||||
name="test",
|
||||
authorization_flow=create_test_flow(),
|
||||
redirect_uris="http://testserver",
|
||||
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://testserver")],
|
||||
signing_key=self.cert,
|
||||
)
|
||||
self.provider.jwks_sources.add(self.source)
|
||||
|
@ -19,7 +19,13 @@ from authentik.providers.oauth2.constants import (
|
||||
TOKEN_TYPE,
|
||||
)
|
||||
from authentik.providers.oauth2.errors import TokenError
|
||||
from authentik.providers.oauth2.models import OAuth2Provider, ScopeMapping
|
||||
from authentik.providers.oauth2.models import (
|
||||
AccessToken,
|
||||
OAuth2Provider,
|
||||
RedirectURI,
|
||||
RedirectURIMatchingMode,
|
||||
ScopeMapping,
|
||||
)
|
||||
from authentik.providers.oauth2.tests.utils import OAuthTestCase
|
||||
|
||||
|
||||
@ -33,7 +39,7 @@ class TestTokenClientCredentialsStandard(OAuthTestCase):
|
||||
self.provider = OAuth2Provider.objects.create(
|
||||
name="test",
|
||||
authorization_flow=create_test_flow(),
|
||||
redirect_uris="http://testserver",
|
||||
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://testserver")],
|
||||
signing_key=create_test_cert(),
|
||||
)
|
||||
self.provider.property_mappings.set(ScopeMapping.objects.all())
|
||||
@ -107,6 +113,48 @@ class TestTokenClientCredentialsStandard(OAuthTestCase):
|
||||
{"error": "invalid_grant", "error_description": TokenError.errors["invalid_grant"]},
|
||||
)
|
||||
|
||||
def test_incorrect_scopes(self):
|
||||
"""test scope that isn't configured"""
|
||||
response = self.client.post(
|
||||
reverse("authentik_providers_oauth2:token"),
|
||||
{
|
||||
"grant_type": GRANT_TYPE_CLIENT_CREDENTIALS,
|
||||
"scope": f"{SCOPE_OPENID} {SCOPE_OPENID_EMAIL} {SCOPE_OPENID_PROFILE} extra_scope",
|
||||
"client_id": self.provider.client_id,
|
||||
"client_secret": self.provider.client_secret,
|
||||
},
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
body = loads(response.content.decode())
|
||||
self.assertEqual(body["token_type"], TOKEN_TYPE)
|
||||
token = AccessToken.objects.filter(
|
||||
provider=self.provider, token=body["access_token"]
|
||||
).first()
|
||||
self.assertSetEqual(
|
||||
set(token.scope), {SCOPE_OPENID, SCOPE_OPENID_EMAIL, SCOPE_OPENID_PROFILE}
|
||||
)
|
||||
_, alg = self.provider.jwt_key
|
||||
jwt = decode(
|
||||
body["access_token"],
|
||||
key=self.provider.signing_key.public_key,
|
||||
algorithms=[alg],
|
||||
audience=self.provider.client_id,
|
||||
)
|
||||
self.assertEqual(
|
||||
jwt["given_name"], "Autogenerated user from application test (client credentials)"
|
||||
)
|
||||
self.assertEqual(jwt["preferred_username"], "ak-test-client_credentials")
|
||||
jwt = decode(
|
||||
body["id_token"],
|
||||
key=self.provider.signing_key.public_key,
|
||||
algorithms=[alg],
|
||||
audience=self.provider.client_id,
|
||||
)
|
||||
self.assertEqual(
|
||||
jwt["given_name"], "Autogenerated user from application test (client credentials)"
|
||||
)
|
||||
self.assertEqual(jwt["preferred_username"], "ak-test-client_credentials")
|
||||
|
||||
def test_successful(self):
|
||||
"""test successful"""
|
||||
response = self.client.post(
|
||||
|
@ -20,7 +20,12 @@ from authentik.providers.oauth2.constants import (
|
||||
TOKEN_TYPE,
|
||||
)
|
||||
from authentik.providers.oauth2.errors import TokenError
|
||||
from authentik.providers.oauth2.models import OAuth2Provider, ScopeMapping
|
||||
from authentik.providers.oauth2.models import (
|
||||
OAuth2Provider,
|
||||
RedirectURI,
|
||||
RedirectURIMatchingMode,
|
||||
ScopeMapping,
|
||||
)
|
||||
from authentik.providers.oauth2.tests.utils import OAuthTestCase
|
||||
|
||||
|
||||
@ -34,7 +39,7 @@ class TestTokenClientCredentialsStandardCompat(OAuthTestCase):
|
||||
self.provider = OAuth2Provider.objects.create(
|
||||
name="test",
|
||||
authorization_flow=create_test_flow(),
|
||||
redirect_uris="http://testserver",
|
||||
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://testserver")],
|
||||
signing_key=create_test_cert(),
|
||||
)
|
||||
self.provider.property_mappings.set(ScopeMapping.objects.all())
|
||||
|
@ -19,7 +19,12 @@ from authentik.providers.oauth2.constants import (
|
||||
TOKEN_TYPE,
|
||||
)
|
||||
from authentik.providers.oauth2.errors import TokenError
|
||||
from authentik.providers.oauth2.models import OAuth2Provider, ScopeMapping
|
||||
from authentik.providers.oauth2.models import (
|
||||
OAuth2Provider,
|
||||
RedirectURI,
|
||||
RedirectURIMatchingMode,
|
||||
ScopeMapping,
|
||||
)
|
||||
from authentik.providers.oauth2.tests.utils import OAuthTestCase
|
||||
|
||||
|
||||
@ -33,7 +38,7 @@ class TestTokenClientCredentialsUserNamePassword(OAuthTestCase):
|
||||
self.provider = OAuth2Provider.objects.create(
|
||||
name="test",
|
||||
authorization_flow=create_test_flow(),
|
||||
redirect_uris="http://testserver",
|
||||
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://testserver")],
|
||||
signing_key=create_test_cert(),
|
||||
)
|
||||
self.provider.property_mappings.set(ScopeMapping.objects.all())
|
||||
|
@ -9,8 +9,19 @@ from authentik.blueprints.tests import apply_blueprint
|
||||
from authentik.core.models import Application
|
||||
from authentik.core.tests.utils import create_test_admin_user, create_test_cert, create_test_flow
|
||||
from authentik.lib.generators import generate_code_fixed_length, generate_id
|
||||
from authentik.providers.oauth2.constants import GRANT_TYPE_DEVICE_CODE
|
||||
from authentik.providers.oauth2.models import DeviceToken, OAuth2Provider, ScopeMapping
|
||||
from authentik.providers.oauth2.constants import (
|
||||
GRANT_TYPE_DEVICE_CODE,
|
||||
SCOPE_OPENID,
|
||||
SCOPE_OPENID_EMAIL,
|
||||
)
|
||||
from authentik.providers.oauth2.models import (
|
||||
AccessToken,
|
||||
DeviceToken,
|
||||
OAuth2Provider,
|
||||
RedirectURI,
|
||||
RedirectURIMatchingMode,
|
||||
ScopeMapping,
|
||||
)
|
||||
from authentik.providers.oauth2.tests.utils import OAuthTestCase
|
||||
|
||||
|
||||
@ -24,7 +35,7 @@ class TestTokenDeviceCode(OAuthTestCase):
|
||||
self.provider = OAuth2Provider.objects.create(
|
||||
name="test",
|
||||
authorization_flow=create_test_flow(),
|
||||
redirect_uris="http://testserver",
|
||||
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://testserver")],
|
||||
signing_key=create_test_cert(),
|
||||
)
|
||||
self.provider.property_mappings.set(ScopeMapping.objects.all())
|
||||
@ -80,3 +91,28 @@ class TestTokenDeviceCode(OAuthTestCase):
|
||||
},
|
||||
)
|
||||
self.assertEqual(res.status_code, 200)
|
||||
|
||||
def test_code_mismatched_scope(self):
|
||||
"""Test code with user (mismatched scopes)"""
|
||||
device_token = DeviceToken.objects.create(
|
||||
provider=self.provider,
|
||||
user_code=generate_code_fixed_length(),
|
||||
device_code=generate_id(),
|
||||
user=self.user,
|
||||
scope=[SCOPE_OPENID, SCOPE_OPENID_EMAIL],
|
||||
)
|
||||
res = self.client.post(
|
||||
reverse("authentik_providers_oauth2:token"),
|
||||
data={
|
||||
"client_id": self.provider.client_id,
|
||||
"grant_type": GRANT_TYPE_DEVICE_CODE,
|
||||
"device_code": device_token.device_code,
|
||||
"scope": f"{SCOPE_OPENID} {SCOPE_OPENID_EMAIL} invalid",
|
||||
},
|
||||
)
|
||||
self.assertEqual(res.status_code, 200)
|
||||
body = loads(res.content)
|
||||
token = AccessToken.objects.filter(
|
||||
provider=self.provider, token=body["access_token"]
|
||||
).first()
|
||||
self.assertSetEqual(set(token.scope), {SCOPE_OPENID, SCOPE_OPENID_EMAIL})
|
||||
|
@ -10,7 +10,12 @@ from authentik.core.models import Application
|
||||
from authentik.core.tests.utils import create_test_admin_user, create_test_flow
|
||||
from authentik.lib.generators import generate_id
|
||||
from authentik.providers.oauth2.constants import GRANT_TYPE_AUTHORIZATION_CODE
|
||||
from authentik.providers.oauth2.models import AuthorizationCode, OAuth2Provider
|
||||
from authentik.providers.oauth2.models import (
|
||||
AuthorizationCode,
|
||||
OAuth2Provider,
|
||||
RedirectURI,
|
||||
RedirectURIMatchingMode,
|
||||
)
|
||||
from authentik.providers.oauth2.tests.utils import OAuthTestCase
|
||||
|
||||
|
||||
@ -30,7 +35,7 @@ class TestTokenPKCE(OAuthTestCase):
|
||||
name=generate_id(),
|
||||
client_id="test",
|
||||
authorization_flow=flow,
|
||||
redirect_uris="foo://localhost",
|
||||
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "foo://localhost")],
|
||||
access_code_validity="seconds=100",
|
||||
)
|
||||
Application.objects.create(name="app", slug="app", provider=provider)
|
||||
@ -93,7 +98,7 @@ class TestTokenPKCE(OAuthTestCase):
|
||||
name=generate_id(),
|
||||
client_id="test",
|
||||
authorization_flow=flow,
|
||||
redirect_uris="foo://localhost",
|
||||
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "foo://localhost")],
|
||||
access_code_validity="seconds=100",
|
||||
)
|
||||
Application.objects.create(name="app", slug="app", provider=provider)
|
||||
@ -154,7 +159,7 @@ class TestTokenPKCE(OAuthTestCase):
|
||||
name=generate_id(),
|
||||
client_id="test",
|
||||
authorization_flow=flow,
|
||||
redirect_uris="foo://localhost",
|
||||
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "foo://localhost")],
|
||||
access_code_validity="seconds=100",
|
||||
)
|
||||
Application.objects.create(name="app", slug="app", provider=provider)
|
||||
@ -210,7 +215,7 @@ class TestTokenPKCE(OAuthTestCase):
|
||||
name=generate_id(),
|
||||
client_id="test",
|
||||
authorization_flow=flow,
|
||||
redirect_uris="foo://localhost",
|
||||
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "foo://localhost")],
|
||||
access_code_validity="seconds=100",
|
||||
)
|
||||
Application.objects.create(name="app", slug="app", provider=provider)
|
||||
|
@ -11,7 +11,14 @@ from authentik.core.models import Application
|
||||
from authentik.core.tests.utils import create_test_admin_user, create_test_cert, create_test_flow
|
||||
from authentik.events.models import Event, EventAction
|
||||
from authentik.lib.generators import generate_id
|
||||
from authentik.providers.oauth2.models import AccessToken, IDToken, OAuth2Provider, ScopeMapping
|
||||
from authentik.providers.oauth2.models import (
|
||||
AccessToken,
|
||||
IDToken,
|
||||
OAuth2Provider,
|
||||
RedirectURI,
|
||||
RedirectURIMatchingMode,
|
||||
ScopeMapping,
|
||||
)
|
||||
from authentik.providers.oauth2.tests.utils import OAuthTestCase
|
||||
|
||||
|
||||
@ -25,7 +32,7 @@ class TestUserinfo(OAuthTestCase):
|
||||
self.provider: OAuth2Provider = OAuth2Provider.objects.create(
|
||||
name=generate_id(),
|
||||
authorization_flow=create_test_flow(),
|
||||
redirect_uris="",
|
||||
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "")],
|
||||
signing_key=create_test_cert(),
|
||||
)
|
||||
self.provider.property_mappings.set(ScopeMapping.objects.all())
|
||||
|
@ -2,7 +2,6 @@
|
||||
|
||||
from dataclasses import InitVar, dataclass, field
|
||||
from datetime import timedelta
|
||||
from hashlib import sha256
|
||||
from json import dumps
|
||||
from re import error as RegexError
|
||||
from re import fullmatch
|
||||
@ -16,7 +15,7 @@ from django.utils import timezone
|
||||
from django.utils.translation import gettext as _
|
||||
from structlog.stdlib import get_logger
|
||||
|
||||
from authentik.core.models import Application
|
||||
from authentik.core.models import Application, AuthenticatedSession
|
||||
from authentik.events.models import Event, EventAction
|
||||
from authentik.events.signals import get_login_event
|
||||
from authentik.flows.challenge import (
|
||||
@ -57,6 +56,8 @@ from authentik.providers.oauth2.models import (
|
||||
AuthorizationCode,
|
||||
GrantTypes,
|
||||
OAuth2Provider,
|
||||
RedirectURI,
|
||||
RedirectURIMatchingMode,
|
||||
ResponseMode,
|
||||
ResponseTypes,
|
||||
ScopeMapping,
|
||||
@ -188,40 +189,39 @@ class OAuthAuthorizationParams:
|
||||
|
||||
def check_redirect_uri(self):
|
||||
"""Redirect URI validation."""
|
||||
allowed_redirect_urls = self.provider.redirect_uris.split()
|
||||
allowed_redirect_urls = self.provider.redirect_uris
|
||||
if not self.redirect_uri:
|
||||
LOGGER.warning("Missing redirect uri.")
|
||||
raise RedirectUriError("", allowed_redirect_urls)
|
||||
|
||||
if self.provider.redirect_uris == "":
|
||||
if len(allowed_redirect_urls) < 1:
|
||||
LOGGER.info("Setting redirect for blank redirect_uris", redirect=self.redirect_uri)
|
||||
self.provider.redirect_uris = self.redirect_uri
|
||||
self.provider.redirect_uris = [
|
||||
RedirectURI(RedirectURIMatchingMode.STRICT, self.redirect_uri)
|
||||
]
|
||||
self.provider.save()
|
||||
allowed_redirect_urls = self.provider.redirect_uris.split()
|
||||
|
||||
if self.provider.redirect_uris == "*":
|
||||
LOGGER.info("Converting redirect_uris to regex", redirect=self.redirect_uri)
|
||||
self.provider.redirect_uris = ".*"
|
||||
self.provider.save()
|
||||
allowed_redirect_urls = self.provider.redirect_uris.split()
|
||||
allowed_redirect_urls = self.provider.redirect_uris
|
||||
|
||||
match_found = False
|
||||
for allowed in allowed_redirect_urls:
|
||||
if allowed.matching_mode == RedirectURIMatchingMode.STRICT:
|
||||
if self.redirect_uri == allowed.url:
|
||||
match_found = True
|
||||
break
|
||||
if allowed.matching_mode == RedirectURIMatchingMode.REGEX:
|
||||
try:
|
||||
if not any(fullmatch(x, self.redirect_uri) for x in allowed_redirect_urls):
|
||||
LOGGER.warning(
|
||||
"Invalid redirect uri (regex comparison)",
|
||||
redirect_uri_given=self.redirect_uri,
|
||||
redirect_uri_expected=allowed_redirect_urls,
|
||||
)
|
||||
raise RedirectUriError(self.redirect_uri, allowed_redirect_urls)
|
||||
if fullmatch(allowed.url, self.redirect_uri):
|
||||
match_found = True
|
||||
break
|
||||
except RegexError as exc:
|
||||
LOGGER.info("Failed to parse regular expression, checking directly", exc=exc)
|
||||
if not any(x == self.redirect_uri for x in allowed_redirect_urls):
|
||||
LOGGER.warning(
|
||||
"Invalid redirect uri (strict comparison)",
|
||||
redirect_uri_given=self.redirect_uri,
|
||||
redirect_uri_expected=allowed_redirect_urls,
|
||||
"Failed to parse regular expression",
|
||||
exc=exc,
|
||||
url=allowed.url,
|
||||
provider=self.provider,
|
||||
)
|
||||
raise RedirectUriError(self.redirect_uri, allowed_redirect_urls) from None
|
||||
if not match_found:
|
||||
raise RedirectUriError(self.redirect_uri, allowed_redirect_urls)
|
||||
# Check against forbidden schemes
|
||||
if urlparse(self.redirect_uri).scheme in FORBIDDEN_URI_SCHEMES:
|
||||
raise RedirectUriError(self.redirect_uri, allowed_redirect_urls)
|
||||
@ -318,7 +318,9 @@ class OAuthAuthorizationParams:
|
||||
expires=now + timedelta_from_string(self.provider.access_code_validity),
|
||||
scope=self.scope,
|
||||
nonce=self.nonce,
|
||||
session_id=sha256(request.session.session_key.encode("ascii")).hexdigest(),
|
||||
session=AuthenticatedSession.objects.filter(
|
||||
session_key=request.session.session_key
|
||||
).first(),
|
||||
)
|
||||
|
||||
if self.code_challenge and self.code_challenge_method:
|
||||
@ -610,7 +612,9 @@ class OAuthFulfillmentStage(StageView):
|
||||
expires=access_token_expiry,
|
||||
provider=self.provider,
|
||||
auth_time=auth_event.created if auth_event else now,
|
||||
session_id=sha256(self.request.session.session_key.encode("ascii")).hexdigest(),
|
||||
session=AuthenticatedSession.objects.filter(
|
||||
session_key=self.request.session.session_key
|
||||
).first(),
|
||||
)
|
||||
|
||||
id_token = IDToken.new(self.provider, token, self.request)
|
||||
|
@ -46,10 +46,10 @@ class TokenIntrospectionParams:
|
||||
if not provider:
|
||||
raise TokenIntrospectionError
|
||||
|
||||
access_token = AccessToken.objects.filter(token=raw_token).first()
|
||||
access_token = AccessToken.objects.filter(token=raw_token, provider=provider).first()
|
||||
if access_token:
|
||||
return TokenIntrospectionParams(access_token, provider)
|
||||
refresh_token = RefreshToken.objects.filter(token=raw_token).first()
|
||||
refresh_token = RefreshToken.objects.filter(token=raw_token, provider=provider).first()
|
||||
if refresh_token:
|
||||
return TokenIntrospectionParams(refresh_token, provider)
|
||||
LOGGER.debug("Token does not exist", token=raw_token)
|
||||
|
@ -158,5 +158,5 @@ class ProviderInfoView(View):
|
||||
OAuth2Provider, pk=application.provider_id
|
||||
)
|
||||
response = super().dispatch(request, *args, **kwargs)
|
||||
cors_allow(request, response, *self.provider.redirect_uris.split("\n"))
|
||||
cors_allow(request, response, *[x.url for x in self.provider.redirect_uris])
|
||||
return response
|
||||
|
@ -58,7 +58,9 @@ from authentik.providers.oauth2.models import (
|
||||
ClientTypes,
|
||||
DeviceToken,
|
||||
OAuth2Provider,
|
||||
RedirectURIMatchingMode,
|
||||
RefreshToken,
|
||||
ScopeMapping,
|
||||
)
|
||||
from authentik.providers.oauth2.utils import TokenResponse, cors_allow, extract_client_auth
|
||||
from authentik.providers.oauth2.views.authorize import FORBIDDEN_URI_SCHEMES
|
||||
@ -77,7 +79,7 @@ class TokenParams:
|
||||
redirect_uri: str
|
||||
grant_type: str
|
||||
state: str
|
||||
scope: list[str]
|
||||
scope: set[str]
|
||||
|
||||
provider: OAuth2Provider
|
||||
|
||||
@ -112,11 +114,26 @@ class TokenParams:
|
||||
redirect_uri=request.POST.get("redirect_uri", ""),
|
||||
grant_type=request.POST.get("grant_type", ""),
|
||||
state=request.POST.get("state", ""),
|
||||
scope=request.POST.get("scope", "").split(),
|
||||
scope=set(request.POST.get("scope", "").split()),
|
||||
# PKCE parameter.
|
||||
code_verifier=request.POST.get("code_verifier"),
|
||||
)
|
||||
|
||||
def __check_scopes(self):
|
||||
allowed_scope_names = set(
|
||||
ScopeMapping.objects.filter(provider__in=[self.provider]).values_list(
|
||||
"scope_name", flat=True
|
||||
)
|
||||
)
|
||||
scopes_to_check = self.scope
|
||||
if not scopes_to_check.issubset(allowed_scope_names):
|
||||
LOGGER.info(
|
||||
"Application requested scopes not configured, setting to overlap",
|
||||
scope_allowed=allowed_scope_names,
|
||||
scope_given=self.scope,
|
||||
)
|
||||
self.scope = self.scope.intersection(allowed_scope_names)
|
||||
|
||||
def __check_policy_access(self, app: Application, request: HttpRequest, **kwargs):
|
||||
with start_span(
|
||||
op="authentik.providers.oauth2.token.policy",
|
||||
@ -149,7 +166,7 @@ class TokenParams:
|
||||
client_id=self.provider.client_id,
|
||||
)
|
||||
raise TokenError("invalid_client")
|
||||
|
||||
self.__check_scopes()
|
||||
if self.grant_type == GRANT_TYPE_AUTHORIZATION_CODE:
|
||||
with start_span(
|
||||
op="authentik.providers.oauth2.post.parse.code",
|
||||
@ -179,42 +196,7 @@ class TokenParams:
|
||||
LOGGER.warning("Missing authorization code")
|
||||
raise TokenError("invalid_grant")
|
||||
|
||||
allowed_redirect_urls = self.provider.redirect_uris.split()
|
||||
# At this point, no provider should have a blank redirect_uri, in case they do
|
||||
# this will check an empty array and raise an error
|
||||
try:
|
||||
if not any(fullmatch(x, self.redirect_uri) for x in allowed_redirect_urls):
|
||||
LOGGER.warning(
|
||||
"Invalid redirect uri (regex comparison)",
|
||||
redirect_uri=self.redirect_uri,
|
||||
expected=allowed_redirect_urls,
|
||||
)
|
||||
Event.new(
|
||||
EventAction.CONFIGURATION_ERROR,
|
||||
message="Invalid redirect URI used by provider",
|
||||
provider=self.provider,
|
||||
redirect_uri=self.redirect_uri,
|
||||
expected=allowed_redirect_urls,
|
||||
).from_http(request)
|
||||
raise TokenError("invalid_client")
|
||||
except RegexError as exc:
|
||||
LOGGER.info("Failed to parse regular expression, checking directly", exc=exc)
|
||||
if not any(x == self.redirect_uri for x in allowed_redirect_urls):
|
||||
LOGGER.warning(
|
||||
"Invalid redirect uri (strict comparison)",
|
||||
redirect_uri=self.redirect_uri,
|
||||
expected=allowed_redirect_urls,
|
||||
)
|
||||
Event.new(
|
||||
EventAction.CONFIGURATION_ERROR,
|
||||
message="Invalid redirect_uri configured",
|
||||
provider=self.provider,
|
||||
).from_http(request)
|
||||
raise TokenError("invalid_client") from None
|
||||
|
||||
# Check against forbidden schemes
|
||||
if urlparse(self.redirect_uri).scheme in FORBIDDEN_URI_SCHEMES:
|
||||
raise TokenError("invalid_request")
|
||||
self.__check_redirect_uri(request)
|
||||
|
||||
self.authorization_code = AuthorizationCode.objects.filter(code=raw_code).first()
|
||||
if not self.authorization_code:
|
||||
@ -254,6 +236,48 @@ class TokenParams:
|
||||
if not self.authorization_code.code_challenge and self.code_verifier:
|
||||
raise TokenError("invalid_grant")
|
||||
|
||||
def __check_redirect_uri(self, request: HttpRequest):
|
||||
allowed_redirect_urls = self.provider.redirect_uris
|
||||
# At this point, no provider should have a blank redirect_uri, in case they do
|
||||
# this will check an empty array and raise an error
|
||||
|
||||
match_found = False
|
||||
for allowed in allowed_redirect_urls:
|
||||
if allowed.matching_mode == RedirectURIMatchingMode.STRICT:
|
||||
if self.redirect_uri == allowed.url:
|
||||
match_found = True
|
||||
break
|
||||
if allowed.matching_mode == RedirectURIMatchingMode.REGEX:
|
||||
try:
|
||||
if fullmatch(allowed.url, self.redirect_uri):
|
||||
match_found = True
|
||||
break
|
||||
except RegexError as exc:
|
||||
LOGGER.warning(
|
||||
"Failed to parse regular expression",
|
||||
exc=exc,
|
||||
url=allowed.url,
|
||||
provider=self.provider,
|
||||
)
|
||||
Event.new(
|
||||
EventAction.CONFIGURATION_ERROR,
|
||||
message="Invalid redirect_uri configured",
|
||||
provider=self.provider,
|
||||
).from_http(request)
|
||||
if not match_found:
|
||||
Event.new(
|
||||
EventAction.CONFIGURATION_ERROR,
|
||||
message="Invalid redirect URI used by provider",
|
||||
provider=self.provider,
|
||||
redirect_uri=self.redirect_uri,
|
||||
expected=allowed_redirect_urls,
|
||||
).from_http(request)
|
||||
raise TokenError("invalid_client")
|
||||
|
||||
# Check against forbidden schemes
|
||||
if urlparse(self.redirect_uri).scheme in FORBIDDEN_URI_SCHEMES:
|
||||
raise TokenError("invalid_request")
|
||||
|
||||
def __post_init_refresh(self, raw_token: str, request: HttpRequest):
|
||||
if not raw_token:
|
||||
LOGGER.warning("Missing refresh token")
|
||||
@ -439,15 +463,14 @@ class TokenParams:
|
||||
# (22 chars being the length of the "template")
|
||||
username=f"ak-{self.provider.name[:150-22]}-client_credentials",
|
||||
defaults={
|
||||
"attributes": {
|
||||
USER_ATTRIBUTE_GENERATED: True,
|
||||
},
|
||||
"last_login": timezone.now(),
|
||||
"name": f"Autogenerated user from application {app.name} (client credentials)",
|
||||
"path": f"{USER_PATH_SYSTEM_PREFIX}/apps/{app.slug}",
|
||||
"type": UserTypes.SERVICE_ACCOUNT,
|
||||
},
|
||||
)
|
||||
self.user.attributes[USER_ATTRIBUTE_GENERATED] = True
|
||||
self.user.save()
|
||||
self.__check_policy_access(app, request)
|
||||
|
||||
Event.new(
|
||||
@ -471,9 +494,6 @@ class TokenParams:
|
||||
self.user, created = User.objects.update_or_create(
|
||||
username=f"{self.provider.name}-{token.get('sub')}",
|
||||
defaults={
|
||||
"attributes": {
|
||||
USER_ATTRIBUTE_GENERATED: True,
|
||||
},
|
||||
"last_login": timezone.now(),
|
||||
"name": (
|
||||
f"Autogenerated user from application {app.name} (client credentials JWT)"
|
||||
@ -482,6 +502,8 @@ class TokenParams:
|
||||
"type": UserTypes.SERVICE_ACCOUNT,
|
||||
},
|
||||
)
|
||||
self.user.attributes[USER_ATTRIBUTE_GENERATED] = True
|
||||
self.user.save()
|
||||
exp = token.get("exp")
|
||||
if created and exp:
|
||||
self.user.attributes[USER_ATTRIBUTE_EXPIRES] = exp
|
||||
@ -499,7 +521,7 @@ class TokenView(View):
|
||||
response = super().dispatch(request, *args, **kwargs)
|
||||
allowed_origins = []
|
||||
if self.provider:
|
||||
allowed_origins = self.provider.redirect_uris.split("\n")
|
||||
allowed_origins = [x.url for x in self.provider.redirect_uris]
|
||||
cors_allow(self.request, response, *allowed_origins)
|
||||
return response
|
||||
|
||||
@ -552,7 +574,7 @@ class TokenView(View):
|
||||
# Keep same scopes as previous token
|
||||
scope=self.params.authorization_code.scope,
|
||||
auth_time=self.params.authorization_code.auth_time,
|
||||
session_id=self.params.authorization_code.session_id,
|
||||
session=self.params.authorization_code.session,
|
||||
)
|
||||
access_id_token = IDToken.new(
|
||||
self.provider,
|
||||
@ -580,7 +602,7 @@ class TokenView(View):
|
||||
expires=refresh_token_expiry,
|
||||
provider=self.provider,
|
||||
auth_time=self.params.authorization_code.auth_time,
|
||||
session_id=self.params.authorization_code.session_id,
|
||||
session=self.params.authorization_code.session,
|
||||
)
|
||||
id_token = IDToken.new(
|
||||
self.provider,
|
||||
@ -613,7 +635,7 @@ class TokenView(View):
|
||||
# Keep same scopes as previous token
|
||||
scope=self.params.refresh_token.scope,
|
||||
auth_time=self.params.refresh_token.auth_time,
|
||||
session_id=self.params.refresh_token.session_id,
|
||||
session=self.params.refresh_token.session,
|
||||
)
|
||||
access_token.id_token = IDToken.new(
|
||||
self.provider,
|
||||
@ -629,7 +651,7 @@ class TokenView(View):
|
||||
expires=refresh_token_expiry,
|
||||
provider=self.provider,
|
||||
auth_time=self.params.refresh_token.auth_time,
|
||||
session_id=self.params.refresh_token.session_id,
|
||||
session=self.params.refresh_token.session,
|
||||
)
|
||||
id_token = IDToken.new(
|
||||
self.provider,
|
||||
@ -687,13 +709,14 @@ class TokenView(View):
|
||||
raise DeviceCodeError("authorization_pending")
|
||||
now = timezone.now()
|
||||
access_token_expiry = now + timedelta_from_string(self.provider.access_token_validity)
|
||||
auth_event = get_login_event(self.request)
|
||||
auth_event = get_login_event(self.params.device_code.session)
|
||||
access_token = AccessToken(
|
||||
provider=self.provider,
|
||||
user=self.params.device_code.user,
|
||||
expires=access_token_expiry,
|
||||
scope=self.params.device_code.scope,
|
||||
auth_time=auth_event.created if auth_event else now,
|
||||
session=self.params.device_code.session,
|
||||
)
|
||||
access_token.id_token = IDToken.new(
|
||||
self.provider,
|
||||
@ -711,7 +734,7 @@ class TokenView(View):
|
||||
"id_token": access_token.id_token.to_jwt(self.provider),
|
||||
}
|
||||
|
||||
if SCOPE_OFFLINE_ACCESS in self.params.scope:
|
||||
if SCOPE_OFFLINE_ACCESS in self.params.device_code.scope:
|
||||
refresh_token_expiry = now + timedelta_from_string(self.provider.refresh_token_validity)
|
||||
refresh_token = RefreshToken(
|
||||
user=self.params.device_code.user,
|
||||
|
@ -108,7 +108,7 @@ class UserInfoView(View):
|
||||
response = super().dispatch(request, *args, **kwargs)
|
||||
allowed_origins = []
|
||||
if self.token:
|
||||
allowed_origins = self.token.provider.redirect_uris.split("\n")
|
||||
allowed_origins = [x.url for x in self.token.provider.redirect_uris]
|
||||
cors_allow(self.request, response, *allowed_origins)
|
||||
return response
|
||||
|
||||
|
@ -121,7 +121,6 @@ class ProxyProviderViewSet(UsedByMixin, ModelViewSet):
|
||||
"basic_auth_password_attribute": ["iexact"],
|
||||
"basic_auth_user_attribute": ["iexact"],
|
||||
"mode": ["iexact"],
|
||||
"redirect_uris": ["iexact"],
|
||||
"cookie_domain": ["iexact"],
|
||||
}
|
||||
search_fields = ["name"]
|
||||
|
@ -13,7 +13,13 @@ from rest_framework.serializers import Serializer
|
||||
from authentik.crypto.models import CertificateKeyPair
|
||||
from authentik.lib.models import DomainlessURLValidator
|
||||
from authentik.outposts.models import OutpostModel
|
||||
from authentik.providers.oauth2.models import ClientTypes, OAuth2Provider, ScopeMapping
|
||||
from authentik.providers.oauth2.models import (
|
||||
ClientTypes,
|
||||
OAuth2Provider,
|
||||
RedirectURI,
|
||||
RedirectURIMatchingMode,
|
||||
ScopeMapping,
|
||||
)
|
||||
|
||||
SCOPE_AK_PROXY = "ak_proxy"
|
||||
OUTPOST_CALLBACK_SIGNATURE = "X-authentik-auth-callback"
|
||||
@ -24,14 +30,15 @@ def get_cookie_secret():
|
||||
return "".join(SystemRandom().choice(string.ascii_uppercase + string.digits) for _ in range(32))
|
||||
|
||||
|
||||
def _get_callback_url(uri: str) -> str:
|
||||
return "\n".join(
|
||||
[
|
||||
def _get_callback_url(uri: str) -> list[RedirectURI]:
|
||||
return [
|
||||
RedirectURI(
|
||||
RedirectURIMatchingMode.STRICT,
|
||||
urljoin(uri, "outpost.goauthentik.io/callback")
|
||||
+ f"\\?{OUTPOST_CALLBACK_SIGNATURE}=true",
|
||||
uri + f"\\?{OUTPOST_CALLBACK_SIGNATURE}=true",
|
||||
),
|
||||
RedirectURI(RedirectURIMatchingMode.STRICT, uri + f"\\?{OUTPOST_CALLBACK_SIGNATURE}=true"),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
class ProxyMode(models.TextChoices):
|
||||
|
@ -1,13 +1,12 @@
|
||||
"""proxy provider tasks"""
|
||||
|
||||
from hashlib import sha256
|
||||
|
||||
from asgiref.sync import async_to_sync
|
||||
from channels.layers import get_channel_layer
|
||||
from django.db import DatabaseError, InternalError, ProgrammingError
|
||||
|
||||
from authentik.outposts.consumer import OUTPOST_GROUP
|
||||
from authentik.outposts.models import Outpost, OutpostType
|
||||
from authentik.providers.oauth2.id_token import hash_session_key
|
||||
from authentik.providers.proxy.models import ProxyProvider
|
||||
from authentik.root.celery import CELERY_APP
|
||||
|
||||
@ -26,7 +25,7 @@ def proxy_set_defaults():
|
||||
def proxy_on_logout(session_id: str):
|
||||
"""Update outpost instances connected to a single outpost"""
|
||||
layer = get_channel_layer()
|
||||
hashed_session_id = sha256(session_id.encode("ascii")).hexdigest()
|
||||
hashed_session_id = hash_session_key(session_id)
|
||||
for outpost in Outpost.objects.filter(type=OutpostType.PROXY):
|
||||
group = OUTPOST_GROUP % {"outpost_pk": str(outpost.pk)}
|
||||
async_to_sync(layer.group_send)(
|
||||
|
@ -50,6 +50,7 @@ class AssertionProcessor:
|
||||
|
||||
_issue_instant: str
|
||||
_assertion_id: str
|
||||
_response_id: str
|
||||
|
||||
_valid_not_before: str
|
||||
_session_not_on_or_after: str
|
||||
@ -62,6 +63,7 @@ class AssertionProcessor:
|
||||
|
||||
self._issue_instant = get_time_string()
|
||||
self._assertion_id = get_random_id()
|
||||
self._response_id = get_random_id()
|
||||
|
||||
self._valid_not_before = get_time_string(
|
||||
timedelta_from_string(self.provider.assertion_valid_not_before)
|
||||
@ -130,7 +132,9 @@ class AssertionProcessor:
|
||||
"""Generate AuthnStatement with AuthnContext and ContextClassRef Elements."""
|
||||
auth_n_statement = Element(f"{{{NS_SAML_ASSERTION}}}AuthnStatement")
|
||||
auth_n_statement.attrib["AuthnInstant"] = self._valid_not_before
|
||||
auth_n_statement.attrib["SessionIndex"] = self._assertion_id
|
||||
auth_n_statement.attrib["SessionIndex"] = sha256(
|
||||
self.http_request.session.session_key.encode("ascii")
|
||||
).hexdigest()
|
||||
auth_n_statement.attrib["SessionNotOnOrAfter"] = self._session_not_on_or_after
|
||||
|
||||
auth_n_context = SubElement(auth_n_statement, f"{{{NS_SAML_ASSERTION}}}AuthnContext")
|
||||
@ -285,7 +289,7 @@ class AssertionProcessor:
|
||||
response.attrib["Version"] = "2.0"
|
||||
response.attrib["IssueInstant"] = self._issue_instant
|
||||
response.attrib["Destination"] = self.provider.acs_url
|
||||
response.attrib["ID"] = get_random_id()
|
||||
response.attrib["ID"] = self._response_id
|
||||
if self.auth_n_request.id:
|
||||
response.attrib["InResponseTo"] = self.auth_n_request.id
|
||||
|
||||
@ -308,7 +312,7 @@ class AssertionProcessor:
|
||||
ref = xmlsec.template.add_reference(
|
||||
signature_node,
|
||||
digest_algorithm_transform,
|
||||
uri="#" + self._assertion_id,
|
||||
uri="#" + element.attrib["ID"],
|
||||
)
|
||||
xmlsec.template.add_transform(ref, xmlsec.constants.TransformEnveloped)
|
||||
xmlsec.template.add_transform(ref, xmlsec.constants.TransformExclC14N)
|
||||
|
@ -180,6 +180,10 @@ class TestAuthNRequest(TestCase):
|
||||
# Now create a response and convert it to string (provider)
|
||||
response_proc = AssertionProcessor(self.provider, http_request, parsed_request)
|
||||
response = response_proc.build_response()
|
||||
# Ensure both response and assertion ID are in the response twice (once as ID attribute,
|
||||
# once as ds:Reference URI)
|
||||
self.assertEqual(response.count(response_proc._assertion_id), 2)
|
||||
self.assertEqual(response.count(response_proc._response_id), 2)
|
||||
|
||||
# Now parse the response (source)
|
||||
http_request.POST = QueryDict(mutable=True)
|
||||
|
@ -2,9 +2,10 @@
|
||||
|
||||
from itertools import batched
|
||||
|
||||
from django.db import transaction
|
||||
from pydantic import ValidationError
|
||||
from pydanticscim.group import GroupMember
|
||||
from pydanticscim.responses import PatchOp, PatchOperation
|
||||
from pydanticscim.responses import PatchOp
|
||||
|
||||
from authentik.core.models import Group
|
||||
from authentik.lib.sync.mapper import PropertyMappingManager
|
||||
@ -19,7 +20,7 @@ from authentik.providers.scim.clients.base import SCIMClient
|
||||
from authentik.providers.scim.clients.exceptions import (
|
||||
SCIMRequestException,
|
||||
)
|
||||
from authentik.providers.scim.clients.schema import SCIM_GROUP_SCHEMA, PatchRequest
|
||||
from authentik.providers.scim.clients.schema import SCIM_GROUP_SCHEMA, PatchOperation, PatchRequest
|
||||
from authentik.providers.scim.clients.schema import Group as SCIMGroupSchema
|
||||
from authentik.providers.scim.models import (
|
||||
SCIMMapping,
|
||||
@ -104,13 +105,47 @@ class SCIMGroupClient(SCIMClient[Group, SCIMProviderGroup, SCIMGroupSchema]):
|
||||
provider=self.provider, group=group, scim_id=scim_id
|
||||
)
|
||||
users = list(group.users.order_by("id").values_list("id", flat=True))
|
||||
self._patch_add_users(group, users)
|
||||
self._patch_add_users(connection, users)
|
||||
return connection
|
||||
|
||||
def update(self, group: Group, connection: SCIMProviderGroup):
|
||||
"""Update existing group"""
|
||||
scim_group = self.to_schema(group, connection)
|
||||
scim_group.id = connection.scim_id
|
||||
try:
|
||||
if self._config.patch.supported:
|
||||
return self._update_patch(group, scim_group, connection)
|
||||
return self._update_put(group, scim_group, connection)
|
||||
except NotFoundSyncException:
|
||||
# Resource missing is handled by self.write, which will re-create the group
|
||||
raise
|
||||
|
||||
def _update_patch(
|
||||
self, group: Group, scim_group: SCIMGroupSchema, connection: SCIMProviderGroup
|
||||
):
|
||||
"""Update a group via PATCH request"""
|
||||
# Patch group's attributes instead of replacing it and re-adding users if we can
|
||||
self._request(
|
||||
"PATCH",
|
||||
f"/Groups/{connection.scim_id}",
|
||||
json=PatchRequest(
|
||||
Operations=[
|
||||
PatchOperation(
|
||||
op=PatchOp.replace,
|
||||
path=None,
|
||||
value=scim_group.model_dump(mode="json", exclude_unset=True),
|
||||
)
|
||||
]
|
||||
).model_dump(
|
||||
mode="json",
|
||||
exclude_unset=True,
|
||||
exclude_none=True,
|
||||
),
|
||||
)
|
||||
return self.patch_compare_users(group)
|
||||
|
||||
def _update_put(self, group: Group, scim_group: SCIMGroupSchema, connection: SCIMProviderGroup):
|
||||
"""Update a group via PUT request"""
|
||||
try:
|
||||
self._request(
|
||||
"PUT",
|
||||
@ -120,33 +155,25 @@ class SCIMGroupClient(SCIMClient[Group, SCIMProviderGroup, SCIMGroupSchema]):
|
||||
exclude_unset=True,
|
||||
),
|
||||
)
|
||||
users = list(group.users.order_by("id").values_list("id", flat=True))
|
||||
return self._patch_add_users(group, users)
|
||||
except NotFoundSyncException:
|
||||
# Resource missing is handled by self.write, which will re-create the group
|
||||
raise
|
||||
return self.patch_compare_users(group)
|
||||
except (SCIMRequestException, ObjectExistsSyncException):
|
||||
# Some providers don't support PUT on groups, so this is mainly a fix for the initial
|
||||
# sync, send patch add requests for all the users the group currently has
|
||||
users = list(group.users.order_by("id").values_list("id", flat=True))
|
||||
self._patch_add_users(group, users)
|
||||
# Also update the group name
|
||||
return self._patch(
|
||||
scim_group.id,
|
||||
PatchOperation(
|
||||
op=PatchOp.replace,
|
||||
path="displayName",
|
||||
value=scim_group.displayName,
|
||||
),
|
||||
)
|
||||
return self._update_patch(group, scim_group, connection)
|
||||
|
||||
def update_group(self, group: Group, action: Direction, users_set: set[int]):
|
||||
"""Update a group, either using PUT to replace it or PATCH if supported"""
|
||||
scim_group = SCIMProviderGroup.objects.filter(provider=self.provider, group=group).first()
|
||||
if not scim_group:
|
||||
self.logger.warning(
|
||||
"could not sync group membership, group does not exist", group=group
|
||||
)
|
||||
return
|
||||
if self._config.patch.supported:
|
||||
if action == Direction.add:
|
||||
return self._patch_add_users(group, users_set)
|
||||
return self._patch_add_users(scim_group, users_set)
|
||||
if action == Direction.remove:
|
||||
return self._patch_remove_users(group, users_set)
|
||||
return self._patch_remove_users(scim_group, users_set)
|
||||
try:
|
||||
return self.write(group)
|
||||
except SCIMRequestException as exc:
|
||||
@ -154,19 +181,24 @@ class SCIMGroupClient(SCIMClient[Group, SCIMProviderGroup, SCIMGroupSchema]):
|
||||
# Assume that provider does not support PUT and also doesn't support
|
||||
# ServiceProviderConfig, so try PATCH as a fallback
|
||||
if action == Direction.add:
|
||||
return self._patch_add_users(group, users_set)
|
||||
return self._patch_add_users(scim_group, users_set)
|
||||
if action == Direction.remove:
|
||||
return self._patch_remove_users(group, users_set)
|
||||
return self._patch_remove_users(scim_group, users_set)
|
||||
raise exc
|
||||
|
||||
def _patch(
|
||||
def _patch_chunked(
|
||||
self,
|
||||
group_id: str,
|
||||
*ops: PatchOperation,
|
||||
):
|
||||
"""Helper function that chunks patch requests based on the maxOperations attribute.
|
||||
This is not strictly according to specs but there's nothing in the schema that allows the
|
||||
us to know what the maximum patch operations per request should be."""
|
||||
chunk_size = self._config.bulk.maxOperations
|
||||
if chunk_size < 1:
|
||||
chunk_size = len(ops)
|
||||
if len(ops) < 1:
|
||||
return
|
||||
for chunk in batched(ops, chunk_size):
|
||||
req = PatchRequest(Operations=list(chunk))
|
||||
self._request(
|
||||
@ -177,16 +209,70 @@ class SCIMGroupClient(SCIMClient[Group, SCIMProviderGroup, SCIMGroupSchema]):
|
||||
),
|
||||
)
|
||||
|
||||
def _patch_add_users(self, group: Group, users_set: set[int]):
|
||||
"""Add users in users_set to group"""
|
||||
if len(users_set) < 1:
|
||||
return
|
||||
@transaction.atomic
|
||||
def patch_compare_users(self, group: Group):
|
||||
"""Compare users with a SCIM group and add/remove any differences"""
|
||||
# Get scim group first
|
||||
scim_group = SCIMProviderGroup.objects.filter(provider=self.provider, group=group).first()
|
||||
if not scim_group:
|
||||
self.logger.warning(
|
||||
"could not sync group membership, group does not exist", group=group
|
||||
)
|
||||
return
|
||||
# Get a list of all users in the authentik group
|
||||
raw_users_should = list(group.users.order_by("id").values_list("id", flat=True))
|
||||
# Lookup the SCIM IDs of the users
|
||||
users_should: list[str] = list(
|
||||
SCIMProviderUser.objects.filter(
|
||||
user__pk__in=raw_users_should, provider=self.provider
|
||||
).values_list("scim_id", flat=True)
|
||||
)
|
||||
if len(raw_users_should) != len(users_should):
|
||||
self.logger.warning(
|
||||
"User count mismatch, not all users in the group are synced to SCIM yet.",
|
||||
group=group,
|
||||
)
|
||||
# Get current group status
|
||||
current_group = SCIMGroupSchema.model_validate(
|
||||
self._request("GET", f"/Groups/{scim_group.scim_id}")
|
||||
)
|
||||
users_to_add = []
|
||||
users_to_remove = []
|
||||
# Check users currently in group and if they shouldn't be in the group and remove them
|
||||
for user in current_group.members or []:
|
||||
if user.value not in users_should:
|
||||
users_to_remove.append(user.value)
|
||||
# Check users that should be in the group and add them
|
||||
for user in users_should:
|
||||
if len([x for x in current_group.members if x.value == user]) < 1:
|
||||
users_to_add.append(user)
|
||||
# Only send request if we need to make changes
|
||||
if len(users_to_add) < 1 and len(users_to_remove) < 1:
|
||||
return
|
||||
return self._patch_chunked(
|
||||
scim_group.scim_id,
|
||||
*[
|
||||
PatchOperation(
|
||||
op=PatchOp.add,
|
||||
path="members",
|
||||
value=[{"value": x}],
|
||||
)
|
||||
for x in users_to_add
|
||||
],
|
||||
*[
|
||||
PatchOperation(
|
||||
op=PatchOp.remove,
|
||||
path="members",
|
||||
value=[{"value": x}],
|
||||
)
|
||||
for x in users_to_remove
|
||||
],
|
||||
)
|
||||
|
||||
def _patch_add_users(self, scim_group: SCIMProviderGroup, users_set: set[int]):
|
||||
"""Add users in users_set to group"""
|
||||
if len(users_set) < 1:
|
||||
return
|
||||
user_ids = list(
|
||||
SCIMProviderUser.objects.filter(
|
||||
user__pk__in=users_set, provider=self.provider
|
||||
@ -194,7 +280,7 @@ class SCIMGroupClient(SCIMClient[Group, SCIMProviderGroup, SCIMGroupSchema]):
|
||||
)
|
||||
if len(user_ids) < 1:
|
||||
return
|
||||
self._patch(
|
||||
self._patch_chunked(
|
||||
scim_group.scim_id,
|
||||
*[
|
||||
PatchOperation(
|
||||
@ -206,16 +292,10 @@ class SCIMGroupClient(SCIMClient[Group, SCIMProviderGroup, SCIMGroupSchema]):
|
||||
],
|
||||
)
|
||||
|
||||
def _patch_remove_users(self, group: Group, users_set: set[int]):
|
||||
def _patch_remove_users(self, scim_group: SCIMProviderGroup, users_set: set[int]):
|
||||
"""Remove users in users_set from group"""
|
||||
if len(users_set) < 1:
|
||||
return
|
||||
scim_group = SCIMProviderGroup.objects.filter(provider=self.provider, group=group).first()
|
||||
if not scim_group:
|
||||
self.logger.warning(
|
||||
"could not sync group membership, group does not exist", group=group
|
||||
)
|
||||
return
|
||||
user_ids = list(
|
||||
SCIMProviderUser.objects.filter(
|
||||
user__pk__in=users_set, provider=self.provider
|
||||
@ -223,7 +303,7 @@ class SCIMGroupClient(SCIMClient[Group, SCIMProviderGroup, SCIMGroupSchema]):
|
||||
)
|
||||
if len(user_ids) < 1:
|
||||
return
|
||||
self._patch(
|
||||
self._patch_chunked(
|
||||
scim_group.scim_id,
|
||||
*[
|
||||
PatchOperation(
|
||||
|
@ -2,6 +2,7 @@
|
||||
|
||||
from pydantic import Field
|
||||
from pydanticscim.group import Group as BaseGroup
|
||||
from pydanticscim.responses import PatchOperation as BasePatchOperation
|
||||
from pydanticscim.responses import PatchRequest as BasePatchRequest
|
||||
from pydanticscim.responses import SCIMError as BaseSCIMError
|
||||
from pydanticscim.service_provider import Bulk as BaseBulk
|
||||
@ -68,6 +69,12 @@ class PatchRequest(BasePatchRequest):
|
||||
schemas: tuple[str] = ("urn:ietf:params:scim:api:messages:2.0:PatchOp",)
|
||||
|
||||
|
||||
class PatchOperation(BasePatchOperation):
|
||||
"""PatchOperation with optional path"""
|
||||
|
||||
path: str | None
|
||||
|
||||
|
||||
class SCIMError(BaseSCIMError):
|
||||
"""SCIM error with optional status code"""
|
||||
|
||||
|
@ -252,3 +252,118 @@ class SCIMMembershipTests(TestCase):
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
def test_member_add_save(self):
|
||||
"""Test member add + save"""
|
||||
config = ServiceProviderConfiguration.default()
|
||||
|
||||
config.patch.supported = True
|
||||
user_scim_id = generate_id()
|
||||
group_scim_id = generate_id()
|
||||
uid = generate_id()
|
||||
group = Group.objects.create(
|
||||
name=uid,
|
||||
)
|
||||
|
||||
user = User.objects.create(username=generate_id())
|
||||
|
||||
# Test initial sync of group creation
|
||||
with Mocker() as mocker:
|
||||
mocker.get(
|
||||
"https://localhost/ServiceProviderConfig",
|
||||
json=config.model_dump(),
|
||||
)
|
||||
mocker.post(
|
||||
"https://localhost/Users",
|
||||
json={
|
||||
"id": user_scim_id,
|
||||
},
|
||||
)
|
||||
mocker.post(
|
||||
"https://localhost/Groups",
|
||||
json={
|
||||
"id": group_scim_id,
|
||||
},
|
||||
)
|
||||
|
||||
self.configure()
|
||||
sync_tasks.trigger_single_task(self.provider, scim_sync).get()
|
||||
|
||||
self.assertEqual(mocker.call_count, 6)
|
||||
self.assertEqual(mocker.request_history[0].method, "GET")
|
||||
self.assertEqual(mocker.request_history[1].method, "GET")
|
||||
self.assertEqual(mocker.request_history[2].method, "GET")
|
||||
self.assertEqual(mocker.request_history[3].method, "POST")
|
||||
self.assertEqual(mocker.request_history[4].method, "GET")
|
||||
self.assertEqual(mocker.request_history[5].method, "POST")
|
||||
self.assertJSONEqual(
|
||||
mocker.request_history[3].body,
|
||||
{
|
||||
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
|
||||
"emails": [],
|
||||
"active": True,
|
||||
"externalId": user.uid,
|
||||
"name": {"familyName": " ", "formatted": " ", "givenName": ""},
|
||||
"displayName": "",
|
||||
"userName": user.username,
|
||||
},
|
||||
)
|
||||
self.assertJSONEqual(
|
||||
mocker.request_history[5].body,
|
||||
{
|
||||
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:Group"],
|
||||
"externalId": str(group.pk),
|
||||
"displayName": group.name,
|
||||
},
|
||||
)
|
||||
|
||||
with Mocker() as mocker:
|
||||
mocker.get(
|
||||
"https://localhost/ServiceProviderConfig",
|
||||
json=config.model_dump(),
|
||||
)
|
||||
mocker.get(
|
||||
f"https://localhost/Groups/{group_scim_id}",
|
||||
json={},
|
||||
)
|
||||
mocker.patch(
|
||||
f"https://localhost/Groups/{group_scim_id}",
|
||||
json={},
|
||||
)
|
||||
group.users.add(user)
|
||||
group.save()
|
||||
self.assertEqual(mocker.call_count, 5)
|
||||
self.assertEqual(mocker.request_history[0].method, "GET")
|
||||
self.assertEqual(mocker.request_history[1].method, "PATCH")
|
||||
self.assertEqual(mocker.request_history[2].method, "GET")
|
||||
self.assertEqual(mocker.request_history[3].method, "PATCH")
|
||||
self.assertEqual(mocker.request_history[4].method, "GET")
|
||||
self.assertJSONEqual(
|
||||
mocker.request_history[1].body,
|
||||
{
|
||||
"schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
|
||||
"Operations": [
|
||||
{
|
||||
"op": "add",
|
||||
"path": "members",
|
||||
"value": [{"value": user_scim_id}],
|
||||
}
|
||||
],
|
||||
},
|
||||
)
|
||||
self.assertJSONEqual(
|
||||
mocker.request_history[3].body,
|
||||
{
|
||||
"Operations": [
|
||||
{
|
||||
"op": "replace",
|
||||
"value": {
|
||||
"id": group_scim_id,
|
||||
"displayName": group.name,
|
||||
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:Group"],
|
||||
"externalId": str(group.pk),
|
||||
},
|
||||
}
|
||||
]
|
||||
},
|
||||
)
|
||||
|
@ -2,6 +2,7 @@
|
||||
|
||||
from collections.abc import Callable
|
||||
from hashlib import sha512
|
||||
from ipaddress import ip_address
|
||||
from time import perf_counter, time
|
||||
from typing import Any
|
||||
|
||||
@ -174,6 +175,7 @@ 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.
|
||||
@ -185,10 +187,15 @@ class ClientIPMiddleware:
|
||||
"HTTP_X_FORWARDED_FOR",
|
||||
"REMOTE_ADDR",
|
||||
)
|
||||
try:
|
||||
for _header in headers:
|
||||
if _header in meta:
|
||||
ips: list[str] = meta.get(_header).split(",")
|
||||
return ips[0].strip()
|
||||
# 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 self.default_ip
|
||||
|
||||
# FIXME: this should probably not be in `root` but rather in a middleware in `outposts`
|
||||
@ -221,12 +228,16 @@ class ClientIPMiddleware:
|
||||
)
|
||||
return None
|
||||
# Update sentry scope to include correct IP
|
||||
sentry_user = Scope.get_isolation_scope()._user or {}
|
||||
sentry_user["ip_address"] = delegated_ip
|
||||
Scope.get_isolation_scope().set_user(sentry_user)
|
||||
user = Scope.get_isolation_scope()._user or {}
|
||||
user["ip_address"] = delegated_ip
|
||||
Scope.get_isolation_scope().set_user(user)
|
||||
# Set the outpost service account on the request
|
||||
setattr(request, self.request_attr_outpost_user, user)
|
||||
return delegated_ip
|
||||
try:
|
||||
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:
|
||||
"""Attempt to get the client's IP by checking common HTTP Headers.
|
||||
|
@ -1,6 +1,8 @@
|
||||
"""Metrics view"""
|
||||
|
||||
from base64 import b64encode
|
||||
from hmac import compare_digest
|
||||
from pathlib import Path
|
||||
from tempfile import gettempdir
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import connections
|
||||
@ -16,22 +18,21 @@ monitoring_set = Signal()
|
||||
|
||||
|
||||
class MetricsView(View):
|
||||
"""Wrapper around ExportToDjangoView, using http-basic auth"""
|
||||
"""Wrapper around ExportToDjangoView with authentication, accessed by the authentik router"""
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
_tmp = Path(gettempdir())
|
||||
with open(_tmp / "authentik-core-metrics.key") as _f:
|
||||
self.monitoring_key = _f.read()
|
||||
|
||||
def get(self, request: HttpRequest) -> HttpResponse:
|
||||
"""Check for HTTP-Basic auth"""
|
||||
auth_header = request.META.get("HTTP_AUTHORIZATION", "")
|
||||
auth_type, _, given_credentials = auth_header.partition(" ")
|
||||
credentials = f"monitor:{settings.SECRET_KEY}"
|
||||
expected = b64encode(str.encode(credentials)).decode()
|
||||
authed = auth_type == "Basic" and given_credentials == expected
|
||||
authed = auth_type == "Bearer" and compare_digest(given_credentials, self.monitoring_key)
|
||||
if not authed and not settings.DEBUG:
|
||||
response = HttpResponse(status=401)
|
||||
response["WWW-Authenticate"] = 'Basic realm="authentik-monitoring"'
|
||||
return response
|
||||
|
||||
return HttpResponse(status=401)
|
||||
monitoring_set.send_robust(self)
|
||||
|
||||
return ExportToDjangoView(request)
|
||||
|
||||
|
||||
|
@ -70,7 +70,6 @@ TENANT_APPS = [
|
||||
"django.contrib.contenttypes",
|
||||
"django.contrib.sessions",
|
||||
"authentik.admin",
|
||||
"authentik.analytics",
|
||||
"authentik.api",
|
||||
"authentik.crypto",
|
||||
"authentik.flows",
|
||||
|
@ -1,8 +1,9 @@
|
||||
"""root tests"""
|
||||
|
||||
from base64 import b64encode
|
||||
from pathlib import Path
|
||||
from secrets import token_urlsafe
|
||||
from tempfile import gettempdir
|
||||
|
||||
from django.conf import settings
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
|
||||
@ -10,6 +11,16 @@ from django.urls import reverse
|
||||
class TestRoot(TestCase):
|
||||
"""Test root application"""
|
||||
|
||||
def setUp(self):
|
||||
_tmp = Path(gettempdir())
|
||||
self.token = token_urlsafe(32)
|
||||
with open(_tmp / "authentik-core-metrics.key", "w") as _f:
|
||||
_f.write(self.token)
|
||||
|
||||
def tearDown(self):
|
||||
_tmp = Path(gettempdir())
|
||||
(_tmp / "authentik-core-metrics.key").unlink()
|
||||
|
||||
def test_monitoring_error(self):
|
||||
"""Test monitoring without any credentials"""
|
||||
response = self.client.get(reverse("metrics"))
|
||||
@ -17,8 +28,7 @@ class TestRoot(TestCase):
|
||||
|
||||
def test_monitoring_ok(self):
|
||||
"""Test monitoring with credentials"""
|
||||
creds = "Basic " + b64encode(f"monitor:{settings.SECRET_KEY}".encode()).decode("utf-8")
|
||||
auth_headers = {"HTTP_AUTHORIZATION": creds}
|
||||
auth_headers = {"HTTP_AUTHORIZATION": f"Bearer {self.token}"}
|
||||
response = self.client.get(reverse("metrics"), **auth_headers)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
|
@ -3,6 +3,7 @@
|
||||
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
|
||||
@ -39,9 +40,8 @@ class LDAPSourceSerializer(SourceSerializer):
|
||||
"""Get cached source connectivity"""
|
||||
return cache.get(CACHE_KEY_STATUS + source.slug, None)
|
||||
|
||||
def validate(self, attrs: dict[str, Any]) -> dict[str, Any]:
|
||||
def validate_sync_users_password(self, sync_users_password: bool) -> bool:
|
||||
"""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,11 +49,31 @@ 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:
|
||||
@ -166,7 +186,8 @@ class LDAPSourceViewSet(UsedByMixin, ModelViewSet):
|
||||
for sync_class in SYNC_CLASSES:
|
||||
class_name = sync_class.name()
|
||||
all_objects.setdefault(class_name, [])
|
||||
for obj in sync_class(source).get_objects(size_limit=10):
|
||||
for page in sync_class(source).get_objects(size_limit=10):
|
||||
for obj in page:
|
||||
obj: dict
|
||||
obj.pop("raw_attributes", None)
|
||||
obj.pop("raw_dn", None)
|
||||
|
@ -26,17 +26,16 @@ 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 (
|
||||
not instance.user_property_mappings.exists()
|
||||
or not instance.group_property_mappings.exists()
|
||||
):
|
||||
if instance.sync_users and not instance.user_property_mappings.exists():
|
||||
return
|
||||
if instance.sync_groups and not instance.group_property_mappings.exists():
|
||||
return
|
||||
ldap_sync_single.delay(instance.pk)
|
||||
ldap_connectivity_check.delay(instance.pk)
|
||||
|
||||
|
||||
@receiver(password_validate)
|
||||
|
4
authentik/sources/ldap/sync/vendor/ms_ad.py
vendored
4
authentik/sources/ldap/sync/vendor/ms_ad.py
vendored
@ -78,7 +78,9 @@ class MicrosoftActiveDirectory(BaseLDAPSynchronizer):
|
||||
# /useraccountcontrol-manipulate-account-properties
|
||||
uac_bit = attributes.get("userAccountControl", 512)
|
||||
uac = UserAccountControl(uac_bit)
|
||||
is_active = UserAccountControl.ACCOUNTDISABLE not in uac
|
||||
is_active = (
|
||||
UserAccountControl.ACCOUNTDISABLE not in uac and UserAccountControl.LOCKOUT not in uac
|
||||
)
|
||||
if is_active != user.is_active:
|
||||
user.is_active = is_active
|
||||
user.save()
|
||||
|
@ -50,3 +50,35 @@ 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())
|
||||
|
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@ -1,12 +1,9 @@
|
||||
"""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
|
||||
@ -15,8 +12,6 @@ from authentik.tenants.models import Tenant
|
||||
class SettingsSerializer(ModelSerializer):
|
||||
"""Settings Serializer"""
|
||||
|
||||
analytics_sources_obj = SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = Tenant
|
||||
fields = [
|
||||
@ -30,19 +25,8 @@ 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"""
|
||||
|
@ -1,26 +0,0 @@
|
||||
# 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
|
||||
),
|
||||
),
|
||||
]
|
@ -4,7 +4,6 @@ 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
|
||||
@ -97,9 +96,6 @@ 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")
|
||||
|
@ -82,3 +82,5 @@ entries:
|
||||
order: 10
|
||||
target: !KeyOf default-authentication-flow-password-binding
|
||||
policy: !KeyOf default-authentication-flow-password-optional
|
||||
attrs:
|
||||
failure_result: true
|
||||
|
@ -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.2 Blueprint schema",
|
||||
"title": "authentik 2024.8.5 Blueprint schema",
|
||||
"required": [
|
||||
"version",
|
||||
"entries"
|
||||
@ -4227,7 +4227,6 @@
|
||||
"model_updated",
|
||||
"model_deleted",
|
||||
"email_sent",
|
||||
"analytics_sent",
|
||||
"update_available",
|
||||
"custom_"
|
||||
],
|
||||
@ -4252,7 +4251,6 @@
|
||||
null,
|
||||
"authentik.tenants",
|
||||
"authentik.admin",
|
||||
"authentik.analytics",
|
||||
"authentik.api",
|
||||
"authentik.crypto",
|
||||
"authentik.flows",
|
||||
@ -5347,9 +5345,30 @@
|
||||
"description": "Key used to sign the tokens. Only required when JWT Algorithm is set to RS256."
|
||||
},
|
||||
"redirect_uris": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"matching_mode": {
|
||||
"type": "string",
|
||||
"title": "Redirect URIs",
|
||||
"description": "Enter each URI on a new line."
|
||||
"enum": [
|
||||
"strict",
|
||||
"regex"
|
||||
],
|
||||
"title": "Matching mode"
|
||||
},
|
||||
"url": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"title": "Url"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"matching_mode",
|
||||
"url"
|
||||
]
|
||||
},
|
||||
"title": "Redirect uris"
|
||||
},
|
||||
"sub_mode": {
|
||||
"type": "string",
|
||||
@ -13118,7 +13137,6 @@
|
||||
"model_updated",
|
||||
"model_deleted",
|
||||
"email_sent",
|
||||
"analytics_sent",
|
||||
"update_available",
|
||||
"custom_"
|
||||
],
|
||||
@ -13280,7 +13298,6 @@
|
||||
"model_updated",
|
||||
"model_deleted",
|
||||
"email_sent",
|
||||
"analytics_sent",
|
||||
"update_available",
|
||||
"custom_"
|
||||
],
|
||||
|
@ -14,11 +14,7 @@ entries:
|
||||
expression: |
|
||||
# This mapping is used by the authentik proxy. It passes extra user attributes,
|
||||
# which are used for example for the HTTP-Basic Authentication mapping.
|
||||
session_id = None
|
||||
if "token" in request.context:
|
||||
session_id = request.context.get("token").session_id
|
||||
return {
|
||||
"sid": session_id,
|
||||
"ak_proxy": {
|
||||
"user_attributes": request.user.group_attributes(request),
|
||||
"is_superuser": request.user.is_superuser,
|
||||
|
@ -31,7 +31,7 @@ services:
|
||||
volumes:
|
||||
- redis:/data
|
||||
server:
|
||||
image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2024.8.2}
|
||||
image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2024.8.5}
|
||||
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.2}
|
||||
image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2024.8.5}
|
||||
restart: unless-stopped
|
||||
command: worker
|
||||
environment:
|
||||
|
10
go.mod
10
go.mod
@ -7,7 +7,7 @@ toolchain go1.23.0
|
||||
require (
|
||||
beryju.io/ldap v0.1.0
|
||||
github.com/coreos/go-oidc/v3 v3.11.0
|
||||
github.com/getsentry/sentry-go v0.29.0
|
||||
github.com/getsentry/sentry-go v0.28.1
|
||||
github.com/go-http-utils/etag v0.0.0-20161124023236-513ea8f21eb1
|
||||
github.com/go-ldap/ldap/v3 v3.4.8
|
||||
github.com/go-openapi/runtime v0.28.0
|
||||
@ -18,20 +18,20 @@ require (
|
||||
github.com/gorilla/securecookie v1.1.2
|
||||
github.com/gorilla/sessions v1.4.0
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/jellydator/ttlcache/v3 v3.3.0
|
||||
github.com/jellydator/ttlcache/v3 v3.2.1
|
||||
github.com/mitchellh/mapstructure v1.5.0
|
||||
github.com/nmcclain/asn1-ber v0.0.0-20170104154839-2661553a0484
|
||||
github.com/pires/go-proxyproto v0.7.0
|
||||
github.com/prometheus/client_golang v1.20.4
|
||||
github.com/prometheus/client_golang v1.20.2
|
||||
github.com/redis/go-redis/v9 v9.6.1
|
||||
github.com/sethvargo/go-envconfig v1.1.0
|
||||
github.com/sirupsen/logrus v1.9.3
|
||||
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.2024082.1
|
||||
goauthentik.io/api/v3 v3.2024064.1
|
||||
golang.org/x/exp v0.0.0-20230210204819-062eb4c674ab
|
||||
golang.org/x/oauth2 v0.23.0
|
||||
golang.org/x/oauth2 v0.22.0
|
||||
golang.org/x/sync v0.8.0
|
||||
gopkg.in/yaml.v2 v2.4.0
|
||||
layeh.com/radius v0.0.0-20210819152912-ad72663a72ab
|
||||
|
20
go.sum
20
go.sum
@ -69,8 +69,8 @@ github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1m
|
||||
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
|
||||
github.com/felixge/httpsnoop v1.0.3 h1:s/nj+GCswXYzN5v2DpNMuMQYe+0DDwt5WVCU6CWBdXk=
|
||||
github.com/felixge/httpsnoop v1.0.3/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||
github.com/getsentry/sentry-go v0.29.0 h1:YtWluuCFg9OfcqnaujpY918N/AhCCwarIDWOYSBAjCA=
|
||||
github.com/getsentry/sentry-go v0.29.0/go.mod h1:jhPesDAL0Q0W2+2YEuVOvdWmVtdsr1+jtBrlDEVWwLY=
|
||||
github.com/getsentry/sentry-go v0.28.1 h1:zzaSm/vHmGllRM6Tpx1492r0YDzauArdBfkJRtY6P5k=
|
||||
github.com/getsentry/sentry-go v0.28.1/go.mod h1:1fQZ+7l7eeJ3wYi82q5Hg8GqAPgefRq+FP/QhafYVgg=
|
||||
github.com/go-asn1-ber/asn1-ber v1.5.5 h1:MNHlNMBDgEKD4TcKr36vQN68BA00aDfjIt3/bD50WnA=
|
||||
github.com/go-asn1-ber/asn1-ber v1.5.5/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
|
||||
github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA=
|
||||
@ -200,8 +200,8 @@ github.com/jcmturner/gokrb5/v8 v8.4.4 h1:x1Sv4HaTpepFkXbt2IkL29DXRf8sOfZXo8eRKh6
|
||||
github.com/jcmturner/gokrb5/v8 v8.4.4/go.mod h1:1btQEpgT6k+unzCwX1KdWMEwPPkkgBtP+F6aCACiMrs=
|
||||
github.com/jcmturner/rpc/v2 v2.0.3 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZY=
|
||||
github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc=
|
||||
github.com/jellydator/ttlcache/v3 v3.3.0 h1:BdoC9cE81qXfrxeb9eoJi9dWrdhSuwXMAnHTbnBm4Wc=
|
||||
github.com/jellydator/ttlcache/v3 v3.3.0/go.mod h1:bj2/e0l4jRnQdrnSTaGTsh4GSXvMjQcy41i7th0GVGw=
|
||||
github.com/jellydator/ttlcache/v3 v3.2.1 h1:eS8ljnYY7BllYGkXw/TfczWZrXUu/CH7SIkC6ugn9Js=
|
||||
github.com/jellydator/ttlcache/v3 v3.2.1/go.mod h1:bj2/e0l4jRnQdrnSTaGTsh4GSXvMjQcy41i7th0GVGw=
|
||||
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
|
||||
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
||||
github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU=
|
||||
@ -239,8 +239,8 @@ 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=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/prometheus/client_golang v1.20.4 h1:Tgh3Yr67PaOv/uTqloMsCEdeuFTatm5zIq5+qNN23vI=
|
||||
github.com/prometheus/client_golang v1.20.4/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE=
|
||||
github.com/prometheus/client_golang v1.20.2 h1:5ctymQzZlyOON1666svgwn3s6IKWgfbjsejTMiXIyjg=
|
||||
github.com/prometheus/client_golang v1.20.2/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE=
|
||||
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
|
||||
github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E=
|
||||
github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY=
|
||||
@ -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.2024082.1 h1:V/3tq3rGK8Fse6xqnVQ8epzzytjXRI93y+jNHen2zMQ=
|
||||
goauthentik.io/api/v3 v3.2024082.1/go.mod h1:zz+mEZg8rY/7eEjkMGWJ2DnGqk+zqxuybGCGrR2O4Kw=
|
||||
goauthentik.io/api/v3 v3.2024064.1 h1:vxquklgDGD+nGFhWRAsQ7ezQKg17MRq6bzEk25fbsb4=
|
||||
goauthentik.io/api/v3 v3.2024064.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=
|
||||
@ -388,8 +388,8 @@ golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4Iltr
|
||||
golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||
golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||
golang.org/x/oauth2 v0.0.0-20210218202405-ba52d332ba99/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
|
||||
golang.org/x/oauth2 v0.23.0 h1:PbgcYx2W7i4LvjJWEbf0ngHV6qJYr86PkAV3bXdLEbs=
|
||||
golang.org/x/oauth2 v0.23.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
|
||||
golang.org/x/oauth2 v0.22.0 h1:BzDx2FehcG7jJwgWLELCdmLuxk2i+x9UDpSiss2u0ZA=
|
||||
golang.org/x/oauth2 v0.22.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
|
||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
|
@ -29,4 +29,4 @@ func UserAgent() string {
|
||||
return fmt.Sprintf("authentik@%s", FullVersion())
|
||||
}
|
||||
|
||||
const VERSION = "2024.8.2"
|
||||
const VERSION = "2024.8.5"
|
||||
|
@ -65,11 +65,8 @@ type Server interface {
|
||||
CryptoStore() *ak.CryptoStore
|
||||
}
|
||||
|
||||
func init() {
|
||||
func NewApplication(p api.ProxyOutpostConfig, c *http.Client, server Server) (*Application, error) {
|
||||
gob.Register(Claims{})
|
||||
}
|
||||
|
||||
func NewApplication(p api.ProxyOutpostConfig, c *http.Client, server Server, oldApp *Application) (*Application, error) {
|
||||
muxLogger := log.WithField("logger", "authentik.outpost.proxyv2.application").WithField("name", p.Name)
|
||||
|
||||
externalHost, err := url.Parse(p.ExternalHost)
|
||||
@ -140,15 +137,7 @@ func NewApplication(p api.ProxyOutpostConfig, c *http.Client, server Server, old
|
||||
isEmbedded: isEmbedded,
|
||||
}
|
||||
go a.authHeaderCache.Start()
|
||||
if oldApp != nil && oldApp.sessions != nil {
|
||||
a.sessions = oldApp.sessions
|
||||
} else {
|
||||
sess, err := a.getStore(p, externalHost)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
a.sessions = sess
|
||||
}
|
||||
a.sessions = a.getStore(p, externalHost)
|
||||
mux.Use(web.NewLoggingHandler(muxLogger, func(l *log.Entry, r *http.Request) *log.Entry {
|
||||
c := a.getClaimsFromSession(r)
|
||||
if c == nil {
|
||||
@ -246,10 +235,11 @@ func NewApplication(p api.ProxyOutpostConfig, c *http.Client, server Server, old
|
||||
// TODO: maybe create event for this?
|
||||
a.log.WithError(err).Warning("failed to compile SkipPathRegex")
|
||||
continue
|
||||
}
|
||||
} else {
|
||||
a.UnauthenticatedRegex = append(a.UnauthenticatedRegex, re)
|
||||
}
|
||||
}
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user