Compare commits

..

20 Commits

Author SHA1 Message Date
76b9add6b4 Merge branch 'main' into web/update-provider-forms-for-invalidation
* main: (142 commits)
  core: bump goauthentik.io/api/v3 from 3.2024102.2 to 3.2024104.1 (#12149)
  core: bump debugpy from 1.8.8 to 1.8.9 (#12150)
  core: bump webauthn from 2.2.0 to 2.3.0 (#12151)
  core: bump pydantic from 2.10.0 to 2.10.1 (#12152)
  translate: Updates for file web/xliff/en.xlf in zh_CN (#12156)
  translate: Updates for file web/xliff/en.xlf in zh-Hans (#12157)
  core: bump sentry-sdk from 2.18.0 to 2.19.0 (#12153)
  web: bump API Client version (#12147)
  root: Backport version change (#12146)
  website/docs: update info about footer links to match new UI (#12120)
  website/docs: prepare release notes (#12142)
  providers/oauth2: fix migration (#12138)
  providers/oauth2: fix migration dependencies (#12123)
  web: bump API Client version (#12129)
  providers/oauth2: fix redirect uri input (#12122)
  providers/proxy: fix redirect_uri (#12121)
  website/docs: prepare release notes (#12119)
  web: bump API Client version (#12118)
  security: fix CVE 2024 52289 (#12113)
  security: fix CVE 2024 52307 (#12115)
  ...
2024-11-22 10:01:43 -08:00
d76e3c8023 Unneeded console.log 2024-11-01 12:46:24 -07:00
fb40ee72a5 Fix merge bug; modify package to always build before testing to catch bugs of this kind. 2024-11-01 10:46:43 -07:00
24f52252ba Merge bug! Lesson of the day: always run npm run build && npm run test:e2e in that order! 2024-11-01 10:27:40 -07:00
26ceb3d6c9 How did that get through? 2024-11-01 10:20:44 -07:00
f756be2ece Merge branch 'main' into web/update-provider-forms-for-invalidation
* main:
  website: bump elliptic from 6.5.7 to 6.6.0 in /website (#11869)
  core: bump selenium from 4.25.0 to 4.26.0 (#11875)
  core: bump goauthentik.io/api/v3 from 3.2024083.14 to 3.2024100.1 (#11876)
  website/docs: add info about invalidation flow, default flows in general (#11800)
  website: fix docs redirect (#11873)
  website: remove RC disclaimer for version 2024.10 (#11871)
  website: update supported versions (#11841)
  web: bump API Client version (#11870)
  root: backport version bump 2024.10.0 (#11868)
  website/docs: 2024.8.4 release notes (#11862)
  web/admin: provide default invalidation flows for LDAP and Radius (#11861)
  core, web: update translations (#11858)
2024-10-31 09:33:14 -07:00
4bfd06e034 Labeling error. 2024-10-30 16:52:22 -07:00
107dff39af Updating these to correspond to changes in MAIN. 2024-10-30 10:36:54 -07:00
401850c5e2 Merge branch 'main' into web/update-provider-forms-for-invalidation
* main:
  web/admin: fix code-based MFA toggle not working in wizard (#11854)
  sources/kerberos: add kiprop to ignored system principals (#11852)
  translate: Updates for file locale/en/LC_MESSAGES/django.po in zh_CN (#11846)
  translate: Updates for file locale/en/LC_MESSAGES/django.po in it (#11845)
  translate: Updates for file web/xliff/en.xlf in zh_CN (#11847)
  translate: Updates for file web/xliff/en.xlf in zh-Hans (#11848)
  translate: Updates for file locale/en/LC_MESSAGES/django.po in zh-Hans (#11849)
  translate: Updates for file web/xliff/en.xlf in it (#11850)
  website: 2024.10 Release Notes (#11839)
  translate: Updates for file web/xliff/en.xlf in zh-Hans (#11814)
  core, web: update translations (#11821)
  core: bump goauthentik.io/api/v3 from 3.2024083.13 to 3.2024083.14 (#11830)
  core: bump service-identity from 24.1.0 to 24.2.0 (#11831)
  core: bump twilio from 9.3.5 to 9.3.6 (#11832)
  core: bump pytest-randomly from 3.15.0 to 3.16.0 (#11833)
  website/docs: Update social-logins github (#11822)
  website/docs: remove � (#11823)
  lifecycle: fix kdc5-config missing (#11826)
  website/docs: update preview status of different features (#11817)
2024-10-29 15:56:05 -07:00
11bc9b8041 Not sure how *that* got lost, but... 2024-10-29 15:51:19 -07:00
807e2a9fb0 web/admin: Unify the forms for providers between the ./admin/providers and ./admin/applications/wizard
## What

- For LDAP, OAuth2, Radius, SAML, SCIM, and Proxy providers, extract the literal form rendering
  component of each provider into a function.  After all, that's what they are: they take input (the
  render state) and produce output (HTML with event handlers).
- Rip out all of the forms in the wizard and replace them with ☝️
- Write E2E tests that exercise *all* of the components in *all* of the forms mentioned. See test
  results.  These tests come in two flavors, "simple" (minimum amount needed to make the provider
  "pass" the backend's parsers) and "complete" (touches every legal field in the form according to
  the authentik `./schema.yml` file).  As a result, every field is validated against the schema
  (although the schema is currently ported into the test by hand.
- Fixed some serious bugginess in the way the wizard `commit` phase handles errors.

## Details

### Providers

In some cases, I broke up the forms into smaller units:

- Proxy, especially, with standalone units now for `renderHttpBasic`, `renderModeSelector`,
`renderSettings`, and the differing modes)
- SAML now has a `renderHasSigningKp` object, which makes that part of the code much more readable.

I also extracted a few of static `options` collections into static const objects, so that the form
object itself would be a bit more readable.

### Wizard

Just ripped out all of the Provider forms.  All of them.  They weren't going to be needed in our
glorious new future.

Using the information provided by the `providerTypes` object, it was easy to extract all of the
information that had once been in `ak-application-wizard-authentication-method-choice.choices`. The
only thing left now is the renderers, one for each of the forms ripped out. Everything else is just
gone.

As a result, though, that's no longer a static list. It has to be derived from information sent via
the API.  So now it's in a context that's built when the wizard is initialized, and accessed by the
`createTypes` pass as well as the specific provider.

The error handling in the `commit` pass was just broken.  I have improved it quite a bit, and now it
actually displays helpful messages when things go wrong.

### Tests

Wrote a simple test runner that iterates through a collection of fields, setting their values via
field-type instructions contained in each line. For example, the "simple" OAuth2 Provider test looks
like this:

```
export const simpleOAuth2ProviderForm: TestProvider = () => [
    [setTypeCreate, "selectProviderType", "OAuth2/OpenID Provider"],
    [clickButton, "Next"],
    [setTextInput, "name", newObjectName("New Oauth2 Provider")],
    [setSearchSelect, "authorizationFlow", /default-provider-authorization-explicit-consent/],
];
```

Each control checks for the existence of the object, and in most cases its current `display`.
(SearchSelect only checks existence, due to the oddness of the portaled popup.)  Where a field can't
reasonably be modified and still pass, we at least verify that the name provided in `schema.yml`
corresponds to an existing, available control on the form or wizard panel.

Combined with a routine for logging in and navigating to the Provider page, and another one to
validate that a new and uniqute "Successfully Created Provider" notification appeared, this makes
testing each provider a simple message of filling out the table of fields you want populated.

Equally simple: these *exact same tests* can be incorporated into a wrapper for logging in,
navigating to the Application page, and filling out an Application, and then a new and unique
Provider for that Application, by Provider Type.

As a special case, the Wizard variant checks the `TestSequence` object returned by the
`TestProvider` function and removes the `name` field, since the Wizard pre-populates that
automatically.

As a result of this, the contents of `./web/src` has lost 1,504 lines of code. And results like
these, where the behavior has been cross-checked three ways (the forms, the tests (and so the
back-end), *and the schema* all agree on field names and behaviors, gives me much more confidence
that the refactor works as expected:

```
[chrome 130.0.6723.70 mac #0-1] Running: chrome (v130.0.6723.70) on mac
[chrome 130.0.6723.70 mac #0-1] Session ID: 039c70690eebc83ffbc2eef97043c774
[chrome 130.0.6723.70 mac #0-1]
[chrome 130.0.6723.70 mac #0-1] » /tests/specs/providers.ts
[chrome 130.0.6723.70 mac #0-1] Configuring Providers
[chrome 130.0.6723.70 mac #0-1]    ✓ Should successfully configure a Simple LDAP provider
[chrome 130.0.6723.70 mac #0-1]    ✓ Should successfully configure a Simple OAuth2 provider
[chrome 130.0.6723.70 mac #0-1]    ✓ Should successfully configure a Simple Radius provider
[chrome 130.0.6723.70 mac #0-1]    ✓ Should successfully configure a Simple SAML provider
[chrome 130.0.6723.70 mac #0-1]    ✓ Should successfully configure a Simple SCIM provider
[chrome 130.0.6723.70 mac #0-1]    ✓ Should successfully configure a Simple Proxy provider
[chrome 130.0.6723.70 mac #0-1]    ✓ Should successfully configure a Simple Forward Auth (single application) provider
[chrome 130.0.6723.70 mac #0-1]    ✓ Should successfully configure a Simple Forward Auth (domain level) provider
[chrome 130.0.6723.70 mac #0-1]    ✓ Should successfully configure a Complete OAuth2 provider
[chrome 130.0.6723.70 mac #0-1]    ✓ Should successfully configure a Complete LDAP provider
[chrome 130.0.6723.70 mac #0-1]    ✓ Should successfully configure a Complete Radius provider
[chrome 130.0.6723.70 mac #0-1]    ✓ Should successfully configure a Complete SAML provider
[chrome 130.0.6723.70 mac #0-1]    ✓ Should successfully configure a Complete SCIM provider
[chrome 130.0.6723.70 mac #0-1]    ✓ Should successfully configure a Complete Proxy provider
[chrome 130.0.6723.70 mac #0-1]    ✓ Should successfully configure a Complete Forward Auth (single application) provider
[chrome 130.0.6723.70 mac #0-1]    ✓ Should successfully configure a Complete Forward Auth (domain level) provider
[chrome 130.0.6723.70 mac #0-1]
[chrome 130.0.6723.70 mac #0-1] 16 passing (1m 48.5s)
------------------------------------------------------------------
[chrome 130.0.6723.70 mac #0-2] Running: chrome (v130.0.6723.70) on mac
[chrome 130.0.6723.70 mac #0-2] Session ID: 5a3ae12c851eff8fffd2686096759146
[chrome 130.0.6723.70 mac #0-2]
[chrome 130.0.6723.70 mac #0-2] » /tests/specs/new-application-by-wizard.ts
[chrome 130.0.6723.70 mac #0-2] Configuring Applications Via the Wizard
[chrome 130.0.6723.70 mac #0-2]    ✓ Should successfully configure an application with a Simple LDAP provider
[chrome 130.0.6723.70 mac #0-2]    ✓ Should successfully configure an application with a Simple OAuth2 provider
[chrome 130.0.6723.70 mac #0-2]    ✓ Should successfully configure an application with a Simple Radius provider
[chrome 130.0.6723.70 mac #0-2]    ✓ Should successfully configure an application with a Simple SAML provider
[chrome 130.0.6723.70 mac #0-2]    ✓ Should successfully configure an application with a Simple SCIM provider
[chrome 130.0.6723.70 mac #0-2]    ✓ Should successfully configure an application with a Simple Proxy provider
[chrome 130.0.6723.70 mac #0-2]    ✓ Should successfully configure an application with a Simple Forward Auth (single) provider
[chrome 130.0.6723.70 mac #0-2]    ✓ Should successfully configure an application with a Simple Forward Auth (domain) provider
[chrome 130.0.6723.70 mac #0-2]    ✓ Should successfully configure an application with a Complete OAuth2 provider
[chrome 130.0.6723.70 mac #0-2]    ✓ Should successfully configure an application with a Complete LDAP provider
[chrome 130.0.6723.70 mac #0-2]    ✓ Should successfully configure an application with a Complete Radius provider
[chrome 130.0.6723.70 mac #0-2]    ✓ Should successfully configure an application with a Complete SAML provider
[chrome 130.0.6723.70 mac #0-2]    ✓ Should successfully configure an application with a Complete SCIM provider
[chrome 130.0.6723.70 mac #0-2]    ✓ Should successfully configure an application with a Complete Proxy provider
[chrome 130.0.6723.70 mac #0-2]    ✓ Should successfully configure an application with a Complete Forward Auth (single) provider
[chrome 130.0.6723.70 mac #0-2]    ✓ Should successfully configure an application with a Complete Forward Auth (domain) provider
[chrome 130.0.6723.70 mac #0-2]
[chrome 130.0.6723.70 mac #0-2] 16 passing (2m 3s)
```

🎉
2024-10-29 15:50:51 -07:00
5bd7cedaba Merge branch 'main' into web/update-provider-forms-for-invalidation
* main: (22 commits)
  lifecycle: fix missing krb5 deps for full testing in image (#11815)
  translate: Updates for file web/xliff/en.xlf in zh-Hans (#11810)
  translate: Updates for file locale/en/LC_MESSAGES/django.po in zh-Hans (#11809)
  translate: Updates for file locale/en/LC_MESSAGES/django.po in zh_CN (#11808)
  web: bump API Client version (#11807)
  core: bump goauthentik.io/api/v3 from 3.2024083.12 to 3.2024083.13 (#11806)
  core: bump ruff from 0.7.0 to 0.7.1 (#11805)
  core: bump twilio from 9.3.4 to 9.3.5 (#11804)
  core, web: update translations (#11803)
  providers/scim: handle no members in group in consistency check (#11801)
  stages/identification: add captcha to identification stage (#11711)
  website/docs: improve root page and redirect (#11798)
  providers/scim: clamp batch size for patch requests (#11797)
  web/admin: fix missing div in wizard forms (#11794)
  providers/proxy: fix handling of AUTHENTIK_HOST_BROWSER (#11722)
  core, web: update translations (#11789)
  core: bump goauthentik.io/api/v3 from 3.2024083.11 to 3.2024083.12 (#11790)
  core: bump gssapi from 1.8.3 to 1.9.0 (#11791)
  web: bump API Client version (#11792)
  stages/authenticator_validate: autoselect last used 2fa device (#11087)
  ...
2024-10-28 09:37:16 -07:00
c0814ad279 Almost there! 2024-10-25 10:27:06 -07:00
99af95b10c Committed harmony on SAML. Streamlined the tests even further. 2024-10-24 09:35:31 -07:00
a36cc820bd Radius form has been isolated. 2024-10-23 15:24:47 -07:00
6ff260df01 Merge branch 'main' into web/update-provider-forms-for-invalidation
* main:
  web/admin: Add InvalidationFlow to Radius Provider dialogues (#11786)
  core, web: update translations (#11782)
  providers/oauth2: fix amr claim not set due to login event not associated (#11780)
2024-10-23 14:45:21 -07:00
e497dbc314 Merge branch 'main' into web/update-provider-forms-for-invalidation
* main: (44 commits)
  web/admin: add strict dompurify config for diagram (#11783)
  core: bump cryptography from 43.0.1 to 43.0.3 (#11750)
  web: bump API Client version (#11781)
  sources: add Kerberos (#10815)
  root: rework CSRF middleware to set secure flag (#11753)
  web/admin: improve invalidation flow default & field grouping (#11769)
  providers/scim: add comparison with existing group on update and delta update users (#11414)
  website: bump mermaid from 10.6.0 to 10.9.3 in /website (#11766)
  web/flows: use dompurify for footer links (#11773)
  core, web: update translations (#11775)
  core: bump goauthentik.io/api/v3 from 3.2024083.10 to 3.2024083.11 (#11776)
  website: bump @types/react from 18.3.11 to 18.3.12 in /website (#11777)
  website: bump http-proxy-middleware from 2.0.6 to 2.0.7 in /website (#11771)
  web: bump API Client version (#11770)
  stages: authenticator_endpoint_gdtc (#10477)
  core: add prompt_data to auth flow (#11702)
  tests/e2e: fix dex tests failing (#11761)
  web/rac: disable DPI scaling (#11757)
  web/admin: update flow background (#11758)
  website/docs: fix some broken links (#11742)
  ...
2024-10-23 14:00:31 -07:00
f9f849574b We have working tests!!!!!! 2024-10-23 10:50:27 -07:00
4439b298bd Still trying to find components by internal text. Still not working. 2024-10-22 14:21:16 -07:00
4af6ecf629 web: Isolate the OAuth2 Provider Form into a reusable rendering function
- Pull the OAuth2 Provider Form `render()` method out into a standalone function.
  - Why: So it can be shared by both the Wizard and the Provider function. The renderer is (or at
    least, can be) a pure function: you give it input and it produces HTML, *and then it stops*.
- Provide a test harness that can test the OAuth2 provider form.
2024-10-22 07:13:04 -07:00
638 changed files with 31506 additions and 45960 deletions

View File

@ -1,16 +1,16 @@
[bumpversion] [bumpversion]
current_version = 2024.12.0 current_version = 2024.10.4
tag = True tag = True
commit = True commit = True
parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)(?:-(?P<rc_t>[a-zA-Z-]+)(?P<rc_n>[1-9]\\d*))? parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)(?:-(?P<rc_t>[a-zA-Z-]+)(?P<rc_n>[1-9]\\d*))?
serialize = serialize =
{major}.{minor}.{patch}-{rc_t}{rc_n} {major}.{minor}.{patch}-{rc_t}{rc_n}
{major}.{minor}.{patch} {major}.{minor}.{patch}
message = release: {new_version} message = release: {new_version}
tag_name = version/{new_version} tag_name = version/{new_version}
[bumpversion:part:rc_t] [bumpversion:part:rc_t]
values = values =
rc rc
final final
optional_value = final optional_value = final
@ -30,5 +30,3 @@ optional_value = final
[bumpversion:file:internal/constants/constants.go] [bumpversion:file:internal/constants/constants.go]
[bumpversion:file:web/src/common/constants.ts] [bumpversion:file:web/src/common/constants.ts]
[bumpversion:file:website/docs/install-config/install/aws/template.yaml]

View File

@ -11,9 +11,9 @@ inputs:
description: "Docker image arch" description: "Docker image arch"
outputs: outputs:
shouldPush: shouldBuild:
description: "Whether to push the image or not" description: "Whether to build image or not"
value: ${{ steps.ev.outputs.shouldPush }} value: ${{ steps.ev.outputs.shouldBuild }}
sha: sha:
description: "sha" description: "sha"

View File

@ -7,14 +7,7 @@ from time import time
parser = configparser.ConfigParser() parser = configparser.ConfigParser()
parser.read(".bumpversion.cfg") parser.read(".bumpversion.cfg")
# Decide if we should push the image or not should_build = str(len(os.environ.get("DOCKER_USERNAME", "")) > 0).lower()
should_push = True
if len(os.environ.get("DOCKER_USERNAME", "")) < 1:
# Don't push if we don't have DOCKER_USERNAME, i.e. no secrets are available
should_push = False
if os.environ.get("GITHUB_REPOSITORY").lower() == "goauthentik/authentik-internal":
# Don't push on the internal repo
should_push = False
branch_name = os.environ["GITHUB_REF"] branch_name = os.environ["GITHUB_REF"]
if os.environ.get("GITHUB_HEAD_REF", "") != "": if os.environ.get("GITHUB_HEAD_REF", "") != "":
@ -71,7 +64,7 @@ def get_attest_image_names(image_with_tags: list[str]):
with open(os.environ["GITHUB_OUTPUT"], "a+", encoding="utf-8") as _output: with open(os.environ["GITHUB_OUTPUT"], "a+", encoding="utf-8") as _output:
print(f"shouldPush={str(should_push).lower()}", file=_output) print(f"shouldBuild={should_build}", file=_output)
print(f"sha={sha}", file=_output) print(f"sha={sha}", file=_output)
print(f"version={version}", file=_output) print(f"version={version}", file=_output)
print(f"prerelease={prerelease}", file=_output) print(f"prerelease={prerelease}", file=_output)

View File

@ -35,7 +35,7 @@ runs:
run: | run: |
export PSQL_TAG=${{ inputs.postgresql_version }} export PSQL_TAG=${{ inputs.postgresql_version }}
docker compose -f .github/actions/setup/docker-compose.yml up -d docker compose -f .github/actions/setup/docker-compose.yml up -d
poetry install --sync poetry install
cd web && npm ci cd web && npm ci
- name: Generate config - name: Generate config
shell: poetry run python {0} shell: poetry run python {0}

View File

@ -7,7 +7,6 @@ on:
workflow_dispatch: workflow_dispatch:
jobs: jobs:
build: build:
if: ${{ github.repository != 'goauthentik/authentik-internal' }}
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions: permissions:
id-token: write id-token: write

View File

@ -7,7 +7,6 @@ on:
workflow_dispatch: workflow_dispatch:
jobs: jobs:
build: build:
if: ${{ github.repository != 'goauthentik/authentik-internal' }}
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- id: generate_token - id: generate_token

View File

@ -1,46 +0,0 @@
name: authentik-ci-aws-cfn
on:
push:
branches:
- main
- next
- version-*
pull_request:
branches:
- main
- version-*
env:
POSTGRES_DB: authentik
POSTGRES_USER: authentik
POSTGRES_PASSWORD: "EK-5jnKfjrGRm<77"
jobs:
check-changes-applied:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup authentik env
uses: ./.github/actions/setup
- uses: actions/setup-node@v4
with:
node-version-file: website/package.json
cache: "npm"
cache-dependency-path: website/package-lock.json
- working-directory: website/
run: |
npm ci
- name: Check changes have been applied
run: |
poetry run make aws-cfn
git diff --exit-code
ci-aws-cfn-mark:
if: always()
needs:
- check-changes-applied
runs-on: ubuntu-latest
steps:
- uses: re-actors/alls-green@release/v1
with:
jobs: ${{ toJSON(needs) }}

View File

@ -134,7 +134,7 @@ jobs:
- name: Setup authentik env - name: Setup authentik env
uses: ./.github/actions/setup uses: ./.github/actions/setup
- name: Create k8s Kind Cluster - name: Create k8s Kind Cluster
uses: helm/kind-action@v1.11.0 uses: helm/kind-action@v1.10.0
- name: run integration - name: run integration
run: | run: |
poetry run coverage run manage.py test tests/integration poetry run coverage run manage.py test tests/integration
@ -209,7 +209,6 @@ jobs:
file: unittest.xml file: unittest.xml
token: ${{ secrets.CODECOV_TOKEN }} token: ${{ secrets.CODECOV_TOKEN }}
ci-core-mark: ci-core-mark:
if: always()
needs: needs:
- lint - lint
- test-migrations - test-migrations
@ -219,9 +218,7 @@ jobs:
- test-e2e - test-e2e
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: re-actors/alls-green@release/v1 - run: echo mark
with:
jobs: ${{ toJSON(needs) }}
build: build:
strategy: strategy:
fail-fast: false fail-fast: false
@ -255,7 +252,7 @@ jobs:
image-name: ghcr.io/goauthentik/dev-server image-name: ghcr.io/goauthentik/dev-server
image-arch: ${{ matrix.arch }} image-arch: ${{ matrix.arch }}
- name: Login to Container Registry - name: Login to Container Registry
if: ${{ steps.ev.outputs.shouldPush == 'true' }} if: ${{ steps.ev.outputs.shouldBuild == 'true' }}
uses: docker/login-action@v3 uses: docker/login-action@v3
with: with:
registry: ghcr.io registry: ghcr.io
@ -272,15 +269,15 @@ jobs:
GEOIPUPDATE_ACCOUNT_ID=${{ secrets.GEOIPUPDATE_ACCOUNT_ID }} GEOIPUPDATE_ACCOUNT_ID=${{ secrets.GEOIPUPDATE_ACCOUNT_ID }}
GEOIPUPDATE_LICENSE_KEY=${{ secrets.GEOIPUPDATE_LICENSE_KEY }} GEOIPUPDATE_LICENSE_KEY=${{ secrets.GEOIPUPDATE_LICENSE_KEY }}
tags: ${{ steps.ev.outputs.imageTags }} tags: ${{ steps.ev.outputs.imageTags }}
push: ${{ steps.ev.outputs.shouldPush == 'true' }} push: ${{ steps.ev.outputs.shouldBuild == 'true' }}
build-args: | build-args: |
GIT_BUILD_HASH=${{ steps.ev.outputs.sha }} GIT_BUILD_HASH=${{ steps.ev.outputs.sha }}
cache-from: type=registry,ref=ghcr.io/goauthentik/dev-server:buildcache cache-from: type=registry,ref=ghcr.io/goauthentik/dev-server:buildcache
cache-to: ${{ steps.ev.outputs.shouldPush == 'true' && 'type=registry,ref=ghcr.io/goauthentik/dev-server:buildcache,mode=max' || '' }} cache-to: ${{ steps.ev.outputs.shouldBuild == 'true' && 'type=registry,ref=ghcr.io/goauthentik/dev-server:buildcache,mode=max' || '' }}
platforms: linux/${{ matrix.arch }} platforms: linux/${{ matrix.arch }}
- uses: actions/attest-build-provenance@v2 - uses: actions/attest-build-provenance@v1
id: attest id: attest
if: ${{ steps.ev.outputs.shouldPush == 'true' }} if: ${{ steps.ev.outputs.shouldBuild == 'true' }}
with: with:
subject-name: ${{ steps.ev.outputs.attestImageNames }} subject-name: ${{ steps.ev.outputs.attestImageNames }}
subject-digest: ${{ steps.push.outputs.digest }} subject-digest: ${{ steps.push.outputs.digest }}
@ -306,7 +303,7 @@ jobs:
with: with:
image-name: ghcr.io/goauthentik/dev-server image-name: ghcr.io/goauthentik/dev-server
- name: Comment on PR - name: Comment on PR
if: ${{ steps.ev.outputs.shouldPush == 'true' }} if: ${{ steps.ev.outputs.shouldBuild == 'true' }}
uses: ./.github/actions/comment-pr-instructions uses: ./.github/actions/comment-pr-instructions
with: with:
tag: ${{ steps.ev.outputs.imageMainTag }} tag: ${{ steps.ev.outputs.imageMainTag }}

View File

@ -49,15 +49,12 @@ jobs:
run: | run: |
go test -timeout 0 -v -race -coverprofile=coverage.out -covermode=atomic -cover ./... go test -timeout 0 -v -race -coverprofile=coverage.out -covermode=atomic -cover ./...
ci-outpost-mark: ci-outpost-mark:
if: always()
needs: needs:
- lint-golint - lint-golint
- test-unittest - test-unittest
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: re-actors/alls-green@release/v1 - run: echo mark
with:
jobs: ${{ toJSON(needs) }}
build-container: build-container:
timeout-minutes: 120 timeout-minutes: 120
needs: needs:
@ -93,7 +90,7 @@ jobs:
with: with:
image-name: ghcr.io/goauthentik/dev-${{ matrix.type }} image-name: ghcr.io/goauthentik/dev-${{ matrix.type }}
- name: Login to Container Registry - name: Login to Container Registry
if: ${{ steps.ev.outputs.shouldPush == 'true' }} if: ${{ steps.ev.outputs.shouldBuild == 'true' }}
uses: docker/login-action@v3 uses: docker/login-action@v3
with: with:
registry: ghcr.io registry: ghcr.io
@ -107,16 +104,16 @@ jobs:
with: with:
tags: ${{ steps.ev.outputs.imageTags }} tags: ${{ steps.ev.outputs.imageTags }}
file: ${{ matrix.type }}.Dockerfile file: ${{ matrix.type }}.Dockerfile
push: ${{ steps.ev.outputs.shouldPush == 'true' }} push: ${{ steps.ev.outputs.shouldBuild == 'true' }}
build-args: | build-args: |
GIT_BUILD_HASH=${{ steps.ev.outputs.sha }} GIT_BUILD_HASH=${{ steps.ev.outputs.sha }}
platforms: linux/amd64,linux/arm64 platforms: linux/amd64,linux/arm64
context: . context: .
cache-from: type=registry,ref=ghcr.io/goauthentik/dev-${{ matrix.type }}:buildcache cache-from: type=registry,ref=ghcr.io/goauthentik/dev-${{ matrix.type }}:buildcache
cache-to: ${{ steps.ev.outputs.shouldPush == 'true' && format('type=registry,ref=ghcr.io/goauthentik/dev-{0}:buildcache,mode=max', matrix.type) || '' }} cache-to: ${{ steps.ev.outputs.shouldBuild == 'true' && format('type=registry,ref=ghcr.io/goauthentik/dev-{0}:buildcache,mode=max', matrix.type) || '' }}
- uses: actions/attest-build-provenance@v2 - uses: actions/attest-build-provenance@v1
id: attest id: attest
if: ${{ steps.ev.outputs.shouldPush == 'true' }} if: ${{ steps.ev.outputs.shouldBuild == 'true' }}
with: with:
subject-name: ${{ steps.ev.outputs.attestImageNames }} subject-name: ${{ steps.ev.outputs.attestImageNames }}
subject-digest: ${{ steps.push.outputs.digest }} subject-digest: ${{ steps.push.outputs.digest }}

View File

@ -61,15 +61,12 @@ jobs:
working-directory: web/ working-directory: web/
run: npm run build run: npm run build
ci-web-mark: ci-web-mark:
if: always()
needs: needs:
- build - build
- lint - lint
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: re-actors/alls-green@release/v1 - run: echo mark
with:
jobs: ${{ toJSON(needs) }}
test: test:
needs: needs:
- ci-web-mark - ci-web-mark

View File

@ -62,13 +62,10 @@ jobs:
working-directory: website/ working-directory: website/
run: npm run ${{ matrix.job }} run: npm run ${{ matrix.job }}
ci-website-mark: ci-website-mark:
if: always()
needs: needs:
- lint - lint
- test - test
- build - build
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: re-actors/alls-green@release/v1 - run: echo mark
with:
jobs: ${{ toJSON(needs) }}

View File

@ -11,7 +11,6 @@ env:
jobs: jobs:
build: build:
if: ${{ github.repository != 'goauthentik/authentik-internal' }}
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- id: generate_token - id: generate_token

View File

@ -7,7 +7,6 @@ on:
jobs: jobs:
clean-ghcr: clean-ghcr:
if: ${{ github.repository != 'goauthentik/authentik-internal' }}
name: Delete old unused container images name: Delete old unused container images
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:

View File

@ -12,7 +12,6 @@ env:
jobs: jobs:
publish-source-docs: publish-source-docs:
if: ${{ github.repository != 'goauthentik/authentik-internal' }}
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 120 timeout-minutes: 120
steps: steps:

View File

@ -11,7 +11,6 @@ permissions:
jobs: jobs:
update-next: update-next:
if: ${{ github.repository != 'goauthentik/authentik-internal' }}
runs-on: ubuntu-latest runs-on: ubuntu-latest
environment: internal-production environment: internal-production
steps: steps:

View File

@ -55,7 +55,7 @@ jobs:
VERSION=${{ github.ref }} VERSION=${{ github.ref }}
tags: ${{ steps.ev.outputs.imageTags }} tags: ${{ steps.ev.outputs.imageTags }}
platforms: linux/amd64,linux/arm64 platforms: linux/amd64,linux/arm64
- uses: actions/attest-build-provenance@v2 - uses: actions/attest-build-provenance@v1
id: attest id: attest
with: with:
subject-name: ${{ steps.ev.outputs.attestImageNames }} subject-name: ${{ steps.ev.outputs.attestImageNames }}
@ -119,7 +119,7 @@ jobs:
file: ${{ matrix.type }}.Dockerfile file: ${{ matrix.type }}.Dockerfile
platforms: linux/amd64,linux/arm64 platforms: linux/amd64,linux/arm64
context: . context: .
- uses: actions/attest-build-provenance@v2 - uses: actions/attest-build-provenance@v1
id: attest id: attest
with: with:
subject-name: ${{ steps.ev.outputs.attestImageNames }} subject-name: ${{ steps.ev.outputs.attestImageNames }}
@ -169,27 +169,6 @@ jobs:
file: ./authentik-outpost-${{ matrix.type }}_${{ matrix.goos }}_${{ matrix.goarch }} file: ./authentik-outpost-${{ matrix.type }}_${{ matrix.goos }}_${{ matrix.goarch }}
asset_name: authentik-outpost-${{ matrix.type }}_${{ matrix.goos }}_${{ matrix.goarch }} asset_name: authentik-outpost-${{ matrix.type }}_${{ matrix.goos }}_${{ matrix.goarch }}
tag: ${{ github.ref }} tag: ${{ github.ref }}
upload-aws-cfn-template:
permissions:
# Needed for AWS login
id-token: write
contents: read
needs:
- build-server
- build-outpost
env:
AWS_REGION: eu-central-1
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: "arn:aws:iam::016170277896:role/github_goauthentik_authentik"
aws-region: ${{ env.AWS_REGION }}
- name: Upload template
run: |
aws s3 cp website/docs/install-config/install/aws/template.yaml s3://authentik-cloudformation-templates/authentik.ecs.${{ github.ref }}.yaml
aws s3 cp website/docs/install-config/install/aws/template.yaml s3://authentik-cloudformation-templates/authentik.ecs.latest.yaml
test-release: test-release:
needs: needs:
- build-server - build-server

View File

@ -1,21 +0,0 @@
name: "authentik-repo-mirror"
on: [push, delete]
jobs:
to_internal:
if: ${{ github.repository != 'goauthentik/authentik-internal' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- if: ${{ env.MIRROR_KEY != '' }}
uses: pixta-dev/repository-mirroring-action@v1
with:
target_repo_url:
git@github.com:goauthentik/authentik-internal.git
ssh_private_key:
${{ secrets.GH_MIRROR_KEY }}
env:
MIRROR_KEY: ${{ secrets.GH_MIRROR_KEY }}

View File

@ -11,7 +11,6 @@ permissions:
jobs: jobs:
stale: stale:
if: ${{ github.repository != 'goauthentik/authentik-internal' }}
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- id: generate_token - id: generate_token

View File

@ -33,8 +33,7 @@
"!If sequence", "!If sequence",
"!Index scalar", "!Index scalar",
"!KeyOf scalar", "!KeyOf scalar",
"!Value scalar", "!Value scalar"
"!AtIndex scalar"
], ],
"typescript.preferences.importModuleSpecifier": "non-relative", "typescript.preferences.importModuleSpecifier": "non-relative",
"typescript.preferences.importModuleSpecifierEnding": "index", "typescript.preferences.importModuleSpecifierEnding": "index",

View File

@ -19,18 +19,10 @@ Dockerfile @goauthentik/infrastructure
*Dockerfile @goauthentik/infrastructure *Dockerfile @goauthentik/infrastructure
.dockerignore @goauthentik/infrastructure .dockerignore @goauthentik/infrastructure
docker-compose.yml @goauthentik/infrastructure docker-compose.yml @goauthentik/infrastructure
Makefile @goauthentik/infrastructure
.editorconfig @goauthentik/infrastructure
CODEOWNERS @goauthentik/infrastructure
# Web # Web
web/ @goauthentik/frontend web/ @goauthentik/frontend
tests/wdio/ @goauthentik/frontend tests/wdio/ @goauthentik/frontend
# Locale
locale/ @goauthentik/backend @goauthentik/frontend
web/xliff/ @goauthentik/backend @goauthentik/frontend
# Docs & Website # Docs & Website
website/ @goauthentik/docs website/ @goauthentik/docs
CODE_OF_CONDUCT.md @goauthentik/docs
# Security # Security
SECURITY.md @goauthentik/security @goauthentik/docs website/docs/security/ @goauthentik/security
website/docs/security/ @goauthentik/security @goauthentik/docs

View File

@ -1 +1 @@
website/docs/developer-docs/index.md website/developer-docs/index.md

View File

@ -5,7 +5,7 @@ PWD = $(shell pwd)
UID = $(shell id -u) UID = $(shell id -u)
GID = $(shell id -g) GID = $(shell id -g)
NPM_VERSION = $(shell python -m scripts.npm_version) NPM_VERSION = $(shell python -m scripts.npm_version)
PY_SOURCES = authentik tests scripts lifecycle .github website/docs/install-config/install/aws PY_SOURCES = authentik tests scripts lifecycle .github
DOCKER_IMAGE ?= "authentik:test" DOCKER_IMAGE ?= "authentik:test"
GEN_API_TS = "gen-ts-api" GEN_API_TS = "gen-ts-api"
@ -252,9 +252,6 @@ website-build:
website-watch: ## Build and watch the documentation website, updating automatically website-watch: ## Build and watch the documentation website, updating automatically
cd website && npm run watch cd website && npm run watch
aws-cfn:
cd website && npm run aws-cfn
######################### #########################
## Docker ## Docker
######################### #########################

View File

@ -2,7 +2,7 @@ authentik takes security very seriously. We follow the rules of [responsible di
## Independent audits and pentests ## Independent audits and pentests
We are committed to engaging in regular pentesting and security audits of authentik. Defining and adhering to a cadence of external testing ensures a stronger probability that our code base, our features, and our architecture is as secure and non-exploitable as possible. For more details about specfic audits and pentests, refer to "Audits and Certificates" in our [Security documentation](https://docs.goauthentik.io/docs/security). In May/June of 2023 [Cure53](https://cure53.de) conducted an audit and pentest. The [results](https://cure53.de/pentest-report_authentik.pdf) are published on the [Cure53 website](https://cure53.de/#publications-2023). For more details about authentik's response to the findings of the audit refer to [2023-06 Cure53 Code audit](https://goauthentik.io/docs/security/2023-06-cure53).
## What authentik classifies as a CVE ## What authentik classifies as a CVE
@ -20,8 +20,8 @@ Even if the issue is not a CVE, we still greatly appreciate your help in hardeni
| Version | Supported | | Version | Supported |
| --------- | --------- | | --------- | --------- |
| 2024.8.x | ✅ |
| 2024.10.x | ✅ | | 2024.10.x | ✅ |
| 2024.12.x | ✅ |
## Reporting a Vulnerability ## Reporting a Vulnerability

View File

@ -2,7 +2,7 @@
from os import environ from os import environ
__version__ = "2024.12.0" __version__ = "2024.10.4"
ENV_GIT_HASH_KEY = "GIT_BUILD_HASH" ENV_GIT_HASH_KEY = "GIT_BUILD_HASH"

View File

@ -126,7 +126,7 @@ class Command(BaseCommand):
def_name_perm = f"model_{model_path}_permissions" def_name_perm = f"model_{model_path}_permissions"
def_path_perm = f"#/$defs/{def_name_perm}" def_path_perm = f"#/$defs/{def_name_perm}"
self.schema["$defs"][def_name_perm] = self.model_permissions(model) self.schema["$defs"][def_name_perm] = self.model_permissions(model)
template = { return {
"type": "object", "type": "object",
"required": ["model", "identifiers"], "required": ["model", "identifiers"],
"properties": { "properties": {
@ -143,11 +143,6 @@ class Command(BaseCommand):
"identifiers": {"$ref": def_path}, "identifiers": {"$ref": def_path},
}, },
} }
# Meta models don't require identifiers, as there's no matching database model to find
if issubclass(model, BaseMetaModel):
del template["properties"]["identifiers"]
template["required"].remove("identifiers")
return template
def field_to_jsonschema(self, field: Field) -> dict: def field_to_jsonschema(self, field: Field) -> dict:
"""Convert a single field to json schema""" """Convert a single field to json schema"""

View File

@ -146,10 +146,6 @@ entries:
] ]
] ]
nested_context: !Context context2 nested_context: !Context context2
at_index_sequence: !AtIndex [!Context sequence, 0]
at_index_sequence_default: !AtIndex [!Context sequence, 100, "non existent"]
at_index_mapping: !AtIndex [!Context mapping, "key2"]
at_index_mapping_default: !AtIndex [!Context mapping, "invalid", "non existent"]
identifiers: identifiers:
name: test name: test
conditions: conditions:

View File

@ -215,10 +215,6 @@ class TestBlueprintsV1(TransactionTestCase):
}, },
"nested_context": "context-nested-value", "nested_context": "context-nested-value",
"env_null": None, "env_null": None,
"at_index_sequence": "foo",
"at_index_sequence_default": "non existent",
"at_index_mapping": 2,
"at_index_mapping_default": "non existent",
} }
).exists() ).exists()
) )

View File

@ -24,10 +24,6 @@ from authentik.lib.sentry import SentryIgnoredException
from authentik.policies.models import PolicyBindingModel from authentik.policies.models import PolicyBindingModel
class UNSET:
"""Used to test whether a key has not been set."""
def get_attrs(obj: SerializerModel) -> dict[str, Any]: def get_attrs(obj: SerializerModel) -> dict[str, Any]:
"""Get object's attributes via their serializer, and convert it to a normal dict""" """Get object's attributes via their serializer, and convert it to a normal dict"""
serializer: Serializer = obj.serializer(obj) serializer: Serializer = obj.serializer(obj)
@ -560,53 +556,6 @@ class Value(EnumeratedItem):
raise EntryInvalidError.from_entry(f"Empty/invalid context: {context}", entry) from exc raise EntryInvalidError.from_entry(f"Empty/invalid context: {context}", entry) from exc
class AtIndex(YAMLTag):
"""Get value at index of a sequence or mapping"""
obj: YAMLTag | dict | list | tuple
attribute: int | str | YAMLTag
default: Any | UNSET
def __init__(self, loader: "BlueprintLoader", node: SequenceNode) -> None:
super().__init__()
self.obj = loader.construct_object(node.value[0])
self.attribute = loader.construct_object(node.value[1])
if len(node.value) == 2: # noqa: PLR2004
self.default = UNSET
else:
self.default = loader.construct_object(node.value[2])
def resolve(self, entry: BlueprintEntry, blueprint: Blueprint) -> Any:
if isinstance(self.obj, YAMLTag):
obj = self.obj.resolve(entry, blueprint)
else:
obj = self.obj
if isinstance(self.attribute, YAMLTag):
attribute = self.attribute.resolve(entry, blueprint)
else:
attribute = self.attribute
if isinstance(obj, list | tuple):
try:
return obj[attribute]
except TypeError as exc:
raise EntryInvalidError.from_entry(
f"Invalid index for list: {attribute}", entry
) from exc
except IndexError as exc:
if self.default is UNSET:
raise EntryInvalidError.from_entry(
f"Index out of range: {attribute}", entry
) from exc
return self.default
if attribute in obj:
return obj[attribute]
else:
if self.default is UNSET:
raise EntryInvalidError.from_entry(f"Key does not exist: {attribute}", entry)
return self.default
class BlueprintDumper(SafeDumper): class BlueprintDumper(SafeDumper):
"""Dump dataclasses to yaml""" """Dump dataclasses to yaml"""
@ -657,7 +606,6 @@ class BlueprintLoader(SafeLoader):
self.add_constructor("!Enumerate", Enumerate) self.add_constructor("!Enumerate", Enumerate)
self.add_constructor("!Value", Value) self.add_constructor("!Value", Value)
self.add_constructor("!Index", Index) self.add_constructor("!Index", Index)
self.add_constructor("!AtIndex", AtIndex)
class EntryInvalidError(SentryIgnoredException): class EntryInvalidError(SentryIgnoredException):

View File

@ -65,12 +65,7 @@ from authentik.lib.utils.reflection import get_apps
from authentik.outposts.models import OutpostServiceConnection from authentik.outposts.models import OutpostServiceConnection
from authentik.policies.models import Policy, PolicyBindingModel from authentik.policies.models import Policy, PolicyBindingModel
from authentik.policies.reputation.models import Reputation from authentik.policies.reputation.models import Reputation
from authentik.providers.oauth2.models import ( from authentik.providers.oauth2.models import AccessToken, AuthorizationCode, RefreshToken
AccessToken,
AuthorizationCode,
DeviceToken,
RefreshToken,
)
from authentik.providers.scim.models import SCIMProviderGroup, SCIMProviderUser from authentik.providers.scim.models import SCIMProviderGroup, SCIMProviderUser
from authentik.rbac.models import Role from authentik.rbac.models import Role
from authentik.sources.scim.models import SCIMSourceGroup, SCIMSourceUser from authentik.sources.scim.models import SCIMSourceGroup, SCIMSourceUser
@ -130,7 +125,6 @@ def excluded_models() -> list[type[Model]]:
MicrosoftEntraProviderGroup, MicrosoftEntraProviderGroup,
EndpointDevice, EndpointDevice,
EndpointDeviceConnection, EndpointDeviceConnection,
DeviceToken,
) )

View File

@ -159,7 +159,7 @@ def blueprints_discovery(self: SystemTask, path: str | None = None):
check_blueprint_v1_file(blueprint) check_blueprint_v1_file(blueprint)
count += 1 count += 1
self.set_status( self.set_status(
TaskStatus.SUCCESSFUL, _("Successfully imported {count} files.".format(count=count)) TaskStatus.SUCCESSFUL, _("Successfully imported %(count)d files." % {"count": count})
) )

View File

@ -84,8 +84,8 @@ class CurrentBrandSerializer(PassiveSerializer):
matched_domain = CharField(source="domain") matched_domain = CharField(source="domain")
branding_title = CharField() branding_title = CharField()
branding_logo = CharField(source="branding_logo_url") branding_logo = CharField()
branding_favicon = CharField(source="branding_favicon_url") branding_favicon = CharField()
ui_footer_links = ListField( ui_footer_links = ListField(
child=FooterLinkSerializer(), child=FooterLinkSerializer(),
read_only=True, read_only=True,

View File

@ -25,7 +25,5 @@ class BrandMiddleware:
locale = brand.default_locale locale = brand.default_locale
if locale != "": if locale != "":
locale_to_set = locale locale_to_set = locale
if locale_to_set: with override(locale_to_set):
with override(locale_to_set): return self.get_response(request)
return self.get_response(request)
return self.get_response(request)

View File

@ -10,7 +10,6 @@ from structlog.stdlib import get_logger
from authentik.crypto.models import CertificateKeyPair from authentik.crypto.models import CertificateKeyPair
from authentik.flows.models import Flow from authentik.flows.models import Flow
from authentik.lib.config import CONFIG
from authentik.lib.models import SerializerModel from authentik.lib.models import SerializerModel
LOGGER = get_logger() LOGGER = get_logger()
@ -72,18 +71,6 @@ class Brand(SerializerModel):
) )
attributes = models.JSONField(default=dict, blank=True) attributes = models.JSONField(default=dict, blank=True)
def branding_logo_url(self) -> str:
"""Get branding_logo with the correct prefix"""
if self.branding_logo.startswith("/static"):
return CONFIG.get("web.path", "/")[:-1] + self.branding_logo
return self.branding_logo
def branding_favicon_url(self) -> str:
"""Get branding_favicon with the correct prefix"""
if self.branding_favicon.startswith("/static"):
return CONFIG.get("web.path", "/")[:-1] + self.branding_favicon
return self.branding_favicon
@property @property
def serializer(self) -> Serializer: def serializer(self) -> Serializer:
from authentik.brands.api import BrandSerializer from authentik.brands.api import BrandSerializer

View File

@ -1,54 +0,0 @@
"""Application Roles API Viewset"""
from django.utils.translation import gettext_lazy as _
from rest_framework.exceptions import ValidationError
from rest_framework.viewsets import ModelViewSet
from authentik.core.api.used_by import UsedByMixin
from authentik.core.api.utils import ModelSerializer
from authentik.core.models import (
Application,
ApplicationEntitlement,
User,
)
class ApplicationEntitlementSerializer(ModelSerializer):
"""ApplicationEntitlement Serializer"""
def validate_app(self, app: Application) -> Application:
"""Ensure user has permission to view"""
user: User = self._context["request"].user
if user.has_perm("view_application", app) or user.has_perm(
"authentik_core.view_application"
):
return app
raise ValidationError(_("User does not have access to application."), code="invalid")
class Meta:
model = ApplicationEntitlement
fields = [
"pbm_uuid",
"name",
"app",
"attributes",
]
class ApplicationEntitlementViewSet(UsedByMixin, ModelViewSet):
"""ApplicationEntitlement Viewset"""
queryset = ApplicationEntitlement.objects.all()
serializer_class = ApplicationEntitlementSerializer
search_fields = [
"pbm_uuid",
"name",
"app",
"attributes",
]
filterset_fields = [
"pbm_uuid",
"name",
"app",
]
ordering = ["name"]

View File

@ -159,9 +159,9 @@ class SourceViewSet(
class UserSourceConnectionSerializer(SourceSerializer): class UserSourceConnectionSerializer(SourceSerializer):
"""User source connection""" """OAuth Source Serializer"""
source_obj = SourceSerializer(read_only=True, source="source") source = SourceSerializer(read_only=True)
class Meta: class Meta:
model = UserSourceConnection model = UserSourceConnection
@ -169,10 +169,10 @@ class UserSourceConnectionSerializer(SourceSerializer):
"pk", "pk",
"user", "user",
"source", "source",
"source_obj",
"created", "created",
] ]
extra_kwargs = { extra_kwargs = {
"user": {"read_only": True},
"created": {"read_only": True}, "created": {"read_only": True},
} }
@ -197,9 +197,9 @@ class UserSourceConnectionViewSet(
class GroupSourceConnectionSerializer(SourceSerializer): class GroupSourceConnectionSerializer(SourceSerializer):
"""Group Source Connection""" """Group Source Connection Serializer"""
source_obj = SourceSerializer(read_only=True) source = SourceSerializer(read_only=True)
class Meta: class Meta:
model = GroupSourceConnection model = GroupSourceConnection
@ -207,11 +207,12 @@ class GroupSourceConnectionSerializer(SourceSerializer):
"pk", "pk",
"group", "group",
"source", "source",
"source_obj",
"identifier", "identifier",
"created", "created",
] ]
extra_kwargs = { extra_kwargs = {
"group": {"read_only": True},
"identifier": {"read_only": True},
"created": {"read_only": True}, "created": {"read_only": True},
} }

View File

@ -22,7 +22,7 @@ from authentik.blueprints.v1.common import (
from authentik.blueprints.v1.importer import Importer from authentik.blueprints.v1.importer import Importer
from authentik.core.api.applications import ApplicationSerializer from authentik.core.api.applications import ApplicationSerializer
from authentik.core.api.utils import PassiveSerializer from authentik.core.api.utils import PassiveSerializer
from authentik.core.models import Application, Provider from authentik.core.models import Provider
from authentik.lib.utils.reflection import all_subclasses from authentik.lib.utils.reflection import all_subclasses
from authentik.policies.api.bindings import PolicyBindingSerializer from authentik.policies.api.bindings import PolicyBindingSerializer
@ -51,13 +51,6 @@ class TransactionProviderField(DictField):
class TransactionPolicyBindingSerializer(PolicyBindingSerializer): class TransactionPolicyBindingSerializer(PolicyBindingSerializer):
"""PolicyBindingSerializer which does not require target as target is set implicitly""" """PolicyBindingSerializer which does not require target as target is set implicitly"""
def validate(self, attrs):
# As the PolicyBindingSerializer checks that the correct things can be bound to a target
# but we don't have a target here as that's set by the blueprint, pass in an empty app
# which will have the correct allowed combination of group/user/policy.
attrs["target"] = Application()
return super().validate(attrs)
class Meta(PolicyBindingSerializer.Meta): class Meta(PolicyBindingSerializer.Meta):
fields = [x for x in PolicyBindingSerializer.Meta.fields if x != "target"] fields = [x for x in PolicyBindingSerializer.Meta.fields if x != "target"]

View File

@ -42,10 +42,8 @@ class ImpersonateMiddleware:
# Ensure that the user is active, otherwise nothing will work # Ensure that the user is active, otherwise nothing will work
request.user.is_active = True request.user.is_active = True
if locale_to_set: with override(locale_to_set):
with override(locale_to_set): return self.get_response(request)
return self.get_response(request)
return self.get_response(request)
class RequestIDMiddleware: class RequestIDMiddleware:

View File

@ -1,45 +0,0 @@
# Generated by Django 5.0.9 on 2024-11-20 15:16
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("authentik_core", "0040_provider_invalidation_flow"),
("authentik_policies", "0011_policybinding_failure_result_and_more"),
]
operations = [
migrations.CreateModel(
name="ApplicationEntitlement",
fields=[
(
"policybindingmodel_ptr",
models.OneToOneField(
auto_created=True,
on_delete=django.db.models.deletion.CASCADE,
parent_link=True,
primary_key=True,
serialize=False,
to="authentik_policies.policybindingmodel",
),
),
("attributes", models.JSONField(blank=True, default=dict)),
("name", models.TextField()),
(
"app",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to="authentik_core.application"
),
),
],
options={
"verbose_name": "Application Entitlement",
"verbose_name_plural": "Application Entitlements",
"unique_together": {("app", "name")},
},
bases=("authentik_policies.policybindingmodel", models.Model),
),
]

View File

@ -314,32 +314,6 @@ class User(SerializerModel, GuardianUserMixin, AttributesMixin, AbstractUser):
always_merger.merge(final_attributes, self.attributes) always_merger.merge(final_attributes, self.attributes)
return final_attributes return final_attributes
def app_entitlements(self, app: "Application | None") -> QuerySet["ApplicationEntitlement"]:
"""Get all entitlements this user has for `app`."""
if not app:
return []
all_groups = self.all_groups()
qs = app.applicationentitlement_set.filter(
Q(
Q(bindings__user=self) | Q(bindings__group__in=all_groups),
bindings__negate=False,
)
| Q(
Q(~Q(bindings__user=self), bindings__user__isnull=False)
| Q(~Q(bindings__group__in=all_groups), bindings__group__isnull=False),
bindings__negate=True,
),
bindings__enabled=True,
).order_by("name")
return qs
def app_entitlements_attributes(self, app: "Application | None") -> dict:
"""Get a dictionary containing all merged attributes from app entitlements for `app`."""
final_attributes = {}
for attrs in self.app_entitlements(app).values_list("attributes", flat=True):
always_merger.merge(final_attributes, attrs)
return final_attributes
@property @property
def serializer(self) -> Serializer: def serializer(self) -> Serializer:
from authentik.core.api.users import UserSerializer from authentik.core.api.users import UserSerializer
@ -607,31 +581,6 @@ class Application(SerializerModel, PolicyBindingModel):
verbose_name_plural = _("Applications") verbose_name_plural = _("Applications")
class ApplicationEntitlement(AttributesMixin, SerializerModel, PolicyBindingModel):
"""Application-scoped entitlement to control authorization in an application"""
name = models.TextField()
app = models.ForeignKey(Application, on_delete=models.CASCADE)
class Meta:
verbose_name = _("Application Entitlement")
verbose_name_plural = _("Application Entitlements")
unique_together = (("app", "name"),)
def __str__(self):
return f"Application Entitlement {self.name} for app {self.app_id}"
@property
def serializer(self) -> type[Serializer]:
from authentik.core.api.application_entitlements import ApplicationEntitlementSerializer
return ApplicationEntitlementSerializer
def supported_policy_binding_targets(self):
return ["group", "user"]
class SourceUserMatchingModes(models.TextChoices): class SourceUserMatchingModes(models.TextChoices):
"""Different modes a source can handle new/returning users""" """Different modes a source can handle new/returning users"""

View File

@ -238,7 +238,13 @@ class SourceFlowManager:
self.request.GET, self.request.GET,
flow_slug=flow_slug, flow_slug=flow_slug,
) )
flow_context.setdefault(PLAN_CONTEXT_REDIRECT, final_redirect) # Ensure redirect is carried through when user was trying to
# authorize application
final_redirect = self.request.session.get(SESSION_KEY_GET, {}).get(
NEXT_ARG_NAME, "authentik_core:if-user"
)
if PLAN_CONTEXT_REDIRECT not in flow_context:
flow_context[PLAN_CONTEXT_REDIRECT] = final_redirect
if not flow: if not flow:
return bad_request_message( return bad_request_message(
@ -259,7 +265,12 @@ class SourceFlowManager:
if stages: if stages:
for stage in stages: for stage in stages:
plan.append_stage(stage) plan.append_stage(stage)
return plan.to_redirect(self.request, flow) self.request.session[SESSION_KEY_PLAN] = plan
return redirect_with_qs(
"authentik_core:if-flow",
self.request.GET,
flow_slug=flow.slug,
)
def handle_auth( def handle_auth(
self, self,

View File

@ -18,7 +18,6 @@ from authentik.core.models import (
) )
from authentik.events.system_tasks import SystemTask, TaskStatus, prefill_task from authentik.events.system_tasks import SystemTask, TaskStatus, prefill_task
from authentik.lib.config import CONFIG from authentik.lib.config import CONFIG
from authentik.lib.utils.db import qs_batch_iter
from authentik.root.celery import CELERY_APP from authentik.root.celery import CELERY_APP
LOGGER = get_logger() LOGGER = get_logger()
@ -35,14 +34,14 @@ def clean_expired_models(self: SystemTask):
cls.objects.all().exclude(expiring=False).exclude(expiring=True, expires__gt=now()) cls.objects.all().exclude(expiring=False).exclude(expiring=True, expires__gt=now())
) )
amount = objects.count() amount = objects.count()
for obj in qs_batch_iter(objects): for obj in objects:
obj.expire_action() obj.expire_action()
LOGGER.debug("Expired models", model=cls, amount=amount) LOGGER.debug("Expired models", model=cls, amount=amount)
messages.append(f"Expired {amount} {cls._meta.verbose_name_plural}") messages.append(f"Expired {amount} {cls._meta.verbose_name_plural}")
# Special case # Special case
amount = 0 amount = 0
for session in qs_batch_iter(AuthenticatedSession.objects.all()): for session in AuthenticatedSession.objects.all():
match CONFIG.get("session_storage", "cache"): match CONFIG.get("session_storage", "cache"):
case "cache": case "cache":
cache_key = f"{KEY_PREFIX}{session.session_key}" cache_key = f"{KEY_PREFIX}{session.session_key}"

View File

@ -9,9 +9,6 @@
versionFamily: "{{ version_family }}", versionFamily: "{{ version_family }}",
versionSubdomain: "{{ version_subdomain }}", versionSubdomain: "{{ version_subdomain }}",
build: "{{ build }}", build: "{{ build }}",
api: {
base: "{{ base_url }}",
},
}; };
window.addEventListener("DOMContentLoaded", function () { window.addEventListener("DOMContentLoaded", function () {
{% for message in messages %} {% for message in messages %}

View File

@ -9,8 +9,8 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
<title>{% block title %}{% trans title|default:brand.branding_title %}{% endblock %}</title> <title>{% block title %}{% trans title|default:brand.branding_title %}{% endblock %}</title>
<link rel="icon" href="{{ brand.branding_favicon_url }}"> <link rel="icon" href="{{ brand.branding_favicon }}">
<link rel="shortcut icon" href="{{ brand.branding_favicon_url }}"> <link rel="shortcut icon" href="{{ brand.branding_favicon }}">
{% block head_before %} {% block head_before %}
{% endblock %} {% endblock %}
<link rel="stylesheet" type="text/css" href="{% static 'dist/authentik.css' %}"> <link rel="stylesheet" type="text/css" href="{% static 'dist/authentik.css' %}">

View File

@ -4,7 +4,7 @@
{% load i18n %} {% load i18n %}
{% block head_before %} {% block head_before %}
<link rel="prefetch" href="{% static 'dist/assets/images/flow_background.jpg' %}" /> <link rel="prefetch" href="/static/dist/assets/images/flow_background.jpg" />
<link rel="stylesheet" type="text/css" href="{% static 'dist/patternfly.min.css' %}"> <link rel="stylesheet" type="text/css" href="{% static 'dist/patternfly.min.css' %}">
<link rel="stylesheet" type="text/css" href="{% static 'dist/theme-dark.css' %}" media="(prefers-color-scheme: dark)"> <link rel="stylesheet" type="text/css" href="{% static 'dist/theme-dark.css' %}" media="(prefers-color-scheme: dark)">
{% include "base/header_js.html" %} {% include "base/header_js.html" %}
@ -13,7 +13,7 @@
{% block head %} {% block head %}
<style> <style>
:root { :root {
--ak-flow-background: url("{% static 'dist/assets/images/flow_background.jpg' %}"); --ak-flow-background: url("/static/dist/assets/images/flow_background.jpg");
--pf-c-background-image--BackgroundImage: var(--ak-flow-background); --pf-c-background-image--BackgroundImage: var(--ak-flow-background);
--pf-c-background-image--BackgroundImage-2x: var(--ak-flow-background); --pf-c-background-image--BackgroundImage-2x: var(--ak-flow-background);
--pf-c-background-image--BackgroundImage--sm: var(--ak-flow-background); --pf-c-background-image--BackgroundImage--sm: var(--ak-flow-background);
@ -50,7 +50,7 @@
<div class="ak-login-container"> <div class="ak-login-container">
<main class="pf-c-login__main"> <main class="pf-c-login__main">
<div class="pf-c-login__main-header pf-c-brand ak-brand"> <div class="pf-c-login__main-header pf-c-brand ak-brand">
<img src="{{ brand.branding_logo_url }}" alt="authentik Logo" /> <img src="{{ brand.branding_logo }}" alt="authentik Logo" />
</div> </div>
<header class="pf-c-login__main-header"> <header class="pf-c-login__main-header">
<h1 class="pf-c-title pf-m-3xl"> <h1 class="pf-c-title pf-m-3xl">

View File

@ -1,153 +0,0 @@
"""Test Application Entitlements API"""
from django.urls import reverse
from guardian.shortcuts import assign_perm
from rest_framework.test import APITestCase
from authentik.core.models import Application, ApplicationEntitlement, Group
from authentik.core.tests.utils import create_test_admin_user, create_test_flow, create_test_user
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
class TestApplicationEntitlements(APITestCase):
"""Test application entitlements"""
def setUp(self) -> None:
self.user = create_test_user()
self.other_user = create_test_user()
self.provider = OAuth2Provider.objects.create(
name="test",
authorization_flow=create_test_flow(),
)
self.app: Application = Application.objects.create(
name=generate_id(),
slug=generate_id(),
provider=self.provider,
)
def test_user(self):
"""Test user-direct assignment"""
ent = ApplicationEntitlement.objects.create(app=self.app, name=generate_id())
PolicyBinding.objects.create(target=ent, user=self.user, order=0)
ents = self.user.app_entitlements(self.app)
self.assertEqual(len(ents), 1)
self.assertEqual(ents[0].name, ent.name)
def test_group(self):
"""Test direct group"""
group = Group.objects.create(name=generate_id())
self.user.ak_groups.add(group)
ent = ApplicationEntitlement.objects.create(app=self.app, name=generate_id())
PolicyBinding.objects.create(target=ent, group=group, order=0)
ents = self.user.app_entitlements(self.app)
self.assertEqual(len(ents), 1)
self.assertEqual(ents[0].name, ent.name)
def test_group_indirect(self):
"""Test indirect group"""
parent = Group.objects.create(name=generate_id())
group = Group.objects.create(name=generate_id(), parent=parent)
self.user.ak_groups.add(group)
ent = ApplicationEntitlement.objects.create(app=self.app, name=generate_id())
PolicyBinding.objects.create(target=ent, group=parent, order=0)
ents = self.user.app_entitlements(self.app)
self.assertEqual(len(ents), 1)
self.assertEqual(ents[0].name, ent.name)
def test_negate_user(self):
"""Test with negate flag"""
ent = ApplicationEntitlement.objects.create(app=self.app, name=generate_id())
PolicyBinding.objects.create(target=ent, user=self.other_user, order=0, negate=True)
ents = self.user.app_entitlements(self.app)
self.assertEqual(len(ents), 1)
self.assertEqual(ents[0].name, ent.name)
def test_negate_group(self):
"""Test with negate flag"""
other_group = Group.objects.create(name=generate_id())
ent = ApplicationEntitlement.objects.create(app=self.app, name=generate_id())
PolicyBinding.objects.create(target=ent, group=other_group, order=0, negate=True)
ents = self.user.app_entitlements(self.app)
self.assertEqual(len(ents), 1)
self.assertEqual(ents[0].name, ent.name)
def test_api_perms_global(self):
"""Test API creation with global permissions"""
assign_perm("authentik_core.add_applicationentitlement", self.user)
assign_perm("authentik_core.view_application", self.user)
self.client.force_login(self.user)
res = self.client.post(
reverse("authentik_api:applicationentitlement-list"),
data={
"name": generate_id(),
"app": self.app.pk,
},
)
self.assertEqual(res.status_code, 201)
def test_api_perms_scoped(self):
"""Test API creation with scoped permissions"""
assign_perm("authentik_core.add_applicationentitlement", self.user)
assign_perm("authentik_core.view_application", self.user, self.app)
self.client.force_login(self.user)
res = self.client.post(
reverse("authentik_api:applicationentitlement-list"),
data={
"name": generate_id(),
"app": self.app.pk,
},
)
self.assertEqual(res.status_code, 201)
def test_api_perms_missing(self):
"""Test API creation with no permissions"""
assign_perm("authentik_core.add_applicationentitlement", self.user)
self.client.force_login(self.user)
res = self.client.post(
reverse("authentik_api:applicationentitlement-list"),
data={
"name": generate_id(),
"app": self.app.pk,
},
)
self.assertEqual(res.status_code, 400)
self.assertJSONEqual(res.content, {"app": ["User does not have access to application."]})
def test_api_bindings_policy(self):
"""Test that API doesn't allow policies to be bound to this"""
ent = ApplicationEntitlement.objects.create(app=self.app, name=generate_id())
policy = DummyPolicy.objects.create(name=generate_id())
admin = create_test_admin_user()
self.client.force_login(admin)
response = self.client.post(
reverse("authentik_api:policybinding-list"),
data={
"target": ent.pbm_uuid,
"policy": policy.pk,
"order": 0,
},
)
self.assertJSONEqual(
response.content.decode(),
{"non_field_errors": ["One of 'group', 'user' must be set."]},
)
def test_api_bindings_group(self):
"""Test that API doesn't allow policies to be bound to this"""
ent = ApplicationEntitlement.objects.create(app=self.app, name=generate_id())
group = Group.objects.create(name=generate_id())
admin = create_test_admin_user()
self.client.force_login(admin)
response = self.client.post(
reverse("authentik_api:policybinding-list"),
data={
"target": ent.pbm_uuid,
"group": group.pk,
"order": 0,
},
)
self.assertEqual(response.status_code, 201)
self.assertTrue(PolicyBinding.objects.filter(target=ent.pbm_uuid).exists())

View File

@ -6,7 +6,6 @@ from django.conf import settings
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django.urls import path from django.urls import path
from authentik.core.api.application_entitlements import ApplicationEntitlementViewSet
from authentik.core.api.applications import ApplicationViewSet from authentik.core.api.applications import ApplicationViewSet
from authentik.core.api.authenticated_sessions import AuthenticatedSessionViewSet from authentik.core.api.authenticated_sessions import AuthenticatedSessionViewSet
from authentik.core.api.devices import AdminDeviceViewSet, DeviceViewSet from authentik.core.api.devices import AdminDeviceViewSet, DeviceViewSet
@ -70,7 +69,6 @@ urlpatterns = [
api_urlpatterns = [ api_urlpatterns = [
("core/authenticated_sessions", AuthenticatedSessionViewSet), ("core/authenticated_sessions", AuthenticatedSessionViewSet),
("core/applications", ApplicationViewSet), ("core/applications", ApplicationViewSet),
("core/application_entitlements", ApplicationEntitlementViewSet),
path( path(
"core/transactional/applications/", "core/transactional/applications/",
TransactionalApplicationView.as_view(), TransactionalApplicationView.as_view(),

View File

@ -17,8 +17,10 @@ from authentik.flows.planner import PLAN_CONTEXT_APPLICATION, FlowPlanner
from authentik.flows.stage import ChallengeStageView from authentik.flows.stage import ChallengeStageView
from authentik.flows.views.executor import ( from authentik.flows.views.executor import (
SESSION_KEY_APPLICATION_PRE, SESSION_KEY_APPLICATION_PRE,
SESSION_KEY_PLAN,
ToDefaultFlow, ToDefaultFlow,
) )
from authentik.lib.utils.urls import redirect_with_qs
from authentik.stages.consent.stage import ( from authentik.stages.consent.stage import (
PLAN_CONTEXT_CONSENT_HEADER, PLAN_CONTEXT_CONSENT_HEADER,
PLAN_CONTEXT_CONSENT_PERMISSIONS, PLAN_CONTEXT_CONSENT_PERMISSIONS,
@ -56,7 +58,8 @@ class RedirectToAppLaunch(View):
except FlowNonApplicableException: except FlowNonApplicableException:
raise Http404 from None raise Http404 from None
plan.insert_stage(in_memory_stage(RedirectToAppStage)) plan.insert_stage(in_memory_stage(RedirectToAppStage))
return plan.to_redirect(request, flow) request.session[SESSION_KEY_PLAN] = plan
return redirect_with_qs("authentik_core:if-flow", request.GET, flow_slug=flow.slug)
class RedirectToAppStage(ChallengeStageView): class RedirectToAppStage(ChallengeStageView):

View File

@ -16,7 +16,6 @@ from authentik.api.v3.config import ConfigView
from authentik.brands.api import CurrentBrandSerializer from authentik.brands.api import CurrentBrandSerializer
from authentik.brands.models import Brand from authentik.brands.models import Brand
from authentik.core.models import UserTypes from authentik.core.models import UserTypes
from authentik.lib.config import CONFIG
from authentik.policies.denied import AccessDeniedResponse from authentik.policies.denied import AccessDeniedResponse
@ -52,7 +51,6 @@ class InterfaceView(TemplateView):
kwargs["version_subdomain"] = f"version-{LOCAL_VERSION.major}-{LOCAL_VERSION.minor}" kwargs["version_subdomain"] = f"version-{LOCAL_VERSION.major}-{LOCAL_VERSION.minor}"
kwargs["build"] = get_build_hash() kwargs["build"] = get_build_hash()
kwargs["url_kwargs"] = self.kwargs kwargs["url_kwargs"] = self.kwargs
kwargs["base_url"] = self.request.build_absolute_uri(CONFIG.get("web.path", "/"))
return super().get_context_data(**kwargs) return super().get_context_data(**kwargs)

View File

@ -85,5 +85,5 @@ def certificate_discovery(self: SystemTask):
if dirty: if dirty:
cert.save() cert.save()
self.set_status( self.set_status(
TaskStatus.SUCCESSFUL, _("Successfully imported {count} files.".format(count=discovered)) TaskStatus.SUCCESSFUL, _("Successfully imported %(count)d files." % {"count": discovered})
) )

View File

@ -6,7 +6,6 @@ from django.http import HttpRequest, HttpResponse, JsonResponse
from django.urls import resolve from django.urls import resolve
from structlog.stdlib import BoundLogger, get_logger from structlog.stdlib import BoundLogger, get_logger
from authentik.core.api.users import UserViewSet
from authentik.enterprise.api import LicenseViewSet from authentik.enterprise.api import LicenseViewSet
from authentik.enterprise.license import LicenseKey from authentik.enterprise.license import LicenseKey
from authentik.enterprise.models import LicenseUsageStatus from authentik.enterprise.models import LicenseUsageStatus
@ -60,9 +59,6 @@ class EnterpriseMiddleware:
# Flow executor is mounted as an API path but explicitly allowed # Flow executor is mounted as an API path but explicitly allowed
if request.resolver_match._func_path == class_to_path(FlowExecutorView): if request.resolver_match._func_path == class_to_path(FlowExecutorView):
return True return True
# Always allow making changes to users, even in case the license has ben exceeded
if request.resolver_match._func_path == class_to_path(UserViewSet):
return True
# Only apply these restrictions to the API # Only apply these restrictions to the API
if "authentik_api" not in request.resolver_match.app_names: if "authentik_api" not in request.resolver_match.app_names:
return True return True

View File

@ -6,8 +6,8 @@
<script src="{% versioned_script 'dist/enterprise/rac/index-%v.js' %}" type="module"></script> <script src="{% versioned_script 'dist/enterprise/rac/index-%v.js' %}" type="module"></script>
<meta name="theme-color" content="#18191a" media="(prefers-color-scheme: dark)"> <meta name="theme-color" content="#18191a" media="(prefers-color-scheme: dark)">
<meta name="theme-color" content="#ffffff" media="(prefers-color-scheme: light)"> <meta name="theme-color" content="#ffffff" media="(prefers-color-scheme: light)">
<link rel="icon" href="{{ tenant.branding_favicon_url }}"> <link rel="icon" href="{{ tenant.branding_favicon }}">
<link rel="shortcut icon" href="{{ tenant.branding_favicon_url }}"> <link rel="shortcut icon" href="{{ tenant.branding_favicon }}">
{% include "base/header_js.html" %} {% include "base/header_js.html" %}
{% endblock %} {% endblock %}

View File

@ -18,7 +18,9 @@ from authentik.flows.exceptions import FlowNonApplicableException
from authentik.flows.models import in_memory_stage from authentik.flows.models import in_memory_stage
from authentik.flows.planner import PLAN_CONTEXT_APPLICATION, FlowPlanner from authentik.flows.planner import PLAN_CONTEXT_APPLICATION, FlowPlanner
from authentik.flows.stage import RedirectStage from authentik.flows.stage import RedirectStage
from authentik.flows.views.executor import SESSION_KEY_PLAN
from authentik.lib.utils.time import timedelta_from_string from authentik.lib.utils.time import timedelta_from_string
from authentik.lib.utils.urls import redirect_with_qs
from authentik.policies.engine import PolicyEngine from authentik.policies.engine import PolicyEngine
@ -54,7 +56,12 @@ class RACStartView(EnterprisePolicyAccessView):
provider=self.provider, provider=self.provider,
) )
) )
return plan.to_redirect(request, self.provider.authorization_flow) request.session[SESSION_KEY_PLAN] = plan
return redirect_with_qs(
"authentik_core:if-flow",
request.GET,
flow_slug=self.provider.authorization_flow.slug,
)
class RACInterface(InterfaceView): class RACInterface(InterfaceView):

View File

@ -4,9 +4,7 @@ from typing import Any
from django.http import HttpRequest, HttpResponse, HttpResponseRedirect from django.http import HttpRequest, HttpResponse, HttpResponseRedirect
from django.template.response import TemplateResponse from django.template.response import TemplateResponse
from django.urls import reverse from django.urls import reverse
from django.utils.decorators import method_decorator
from django.views import View from django.views import View
from django.views.decorators.clickjacking import xframe_options_sameorigin
from googleapiclient.discovery import build from googleapiclient.discovery import build
from authentik.enterprise.stages.authenticator_endpoint_gdtc.models import ( from authentik.enterprise.stages.authenticator_endpoint_gdtc.models import (
@ -28,7 +26,6 @@ HEADER_ACCESS_CHALLENGE_RESPONSE = "X-Verified-Access-Challenge-Response"
DEVICE_TRUST_VERIFIED_ACCESS = "VerifiedAccess" DEVICE_TRUST_VERIFIED_ACCESS = "VerifiedAccess"
@method_decorator(xframe_options_sameorigin, name="dispatch")
class GoogleChromeDeviceTrustConnector(View): class GoogleChromeDeviceTrustConnector(View):
"""Google Chrome Device-trust connector based endpoint authenticator""" """Google Chrome Device-trust connector based endpoint authenticator"""

View File

@ -215,49 +215,3 @@ class TestReadOnly(FlowTestCase):
{"detail": "Request denied due to expired/invalid license.", "code": "denied_license"}, {"detail": "Request denied due to expired/invalid license.", "code": "denied_license"},
) )
self.assertEqual(response.status_code, 400) self.assertEqual(response.status_code, 400)
@patch(
"authentik.enterprise.license.LicenseKey.validate",
MagicMock(
return_value=LicenseKey(
aud="",
exp=expiry_valid,
name=generate_id(),
internal_users=100,
external_users=100,
)
),
)
@patch(
"authentik.enterprise.license.LicenseKey.get_internal_user_count",
MagicMock(return_value=1000),
)
@patch(
"authentik.enterprise.license.LicenseKey.get_external_user_count",
MagicMock(return_value=1000),
)
@patch(
"authentik.enterprise.license.LicenseKey.record_usage",
MagicMock(),
)
def test_manage_users(self):
"""Test that managing users is still possible"""
License.objects.create(key=generate_id())
usage = LicenseUsage.objects.create(
internal_user_count=100,
external_user_count=100,
status=LicenseUsageStatus.VALID,
)
usage.record_date = now() - timedelta(weeks=THRESHOLD_READ_ONLY_WEEKS + 1)
usage.save(update_fields=["record_date"])
admin = create_test_admin_user()
self.client.force_login(admin)
# Reading is always allowed
response = self.client.get(reverse("authentik_api:user-list"))
self.assertEqual(response.status_code, 200)
# Writing should also be allowed
response = self.client.patch(reverse("authentik_api:user-detail", kwargs={"pk": admin.pk}))
self.assertEqual(response.status_code, 200)

View File

@ -15,7 +15,6 @@ from authentik.events.models import (
TaskStatus, TaskStatus,
) )
from authentik.events.system_tasks import SystemTask, prefill_task from authentik.events.system_tasks import SystemTask, prefill_task
from authentik.lib.utils.db import qs_batch_iter
from authentik.policies.engine import PolicyEngine from authentik.policies.engine import PolicyEngine
from authentik.policies.models import PolicyBinding, PolicyEngineMode from authentik.policies.models import PolicyBinding, PolicyEngineMode
from authentik.root.celery import CELERY_APP from authentik.root.celery import CELERY_APP
@ -130,8 +129,7 @@ def gdpr_cleanup(user_pk: int):
"""cleanup events from gdpr_compliance""" """cleanup events from gdpr_compliance"""
events = Event.objects.filter(user__pk=user_pk) events = Event.objects.filter(user__pk=user_pk)
LOGGER.debug("GDPR cleanup, removing events from user", events=events.count()) LOGGER.debug("GDPR cleanup, removing events from user", events=events.count())
for event in qs_batch_iter(events): events.delete()
event.delete()
@CELERY_APP.task(bind=True, base=SystemTask) @CELERY_APP.task(bind=True, base=SystemTask)
@ -140,7 +138,7 @@ def notification_cleanup(self: SystemTask):
"""Cleanup seen notifications and notifications whose event expired.""" """Cleanup seen notifications and notifications whose event expired."""
notifications = Notification.objects.filter(Q(event=None) | Q(seen=True)) notifications = Notification.objects.filter(Q(event=None) | Q(seen=True))
amount = notifications.count() amount = notifications.count()
for notification in qs_batch_iter(notifications): for notification in notifications:
notification.delete() notification.delete()
LOGGER.debug("Expired notifications", amount=amount) LOGGER.debug("Expired notifications", amount=amount)
self.set_status(TaskStatus.SUCCESSFUL, f"Expired {amount} Notifications") self.set_status(TaskStatus.SUCCESSFUL, f"Expired {amount} Notifications")

View File

@ -40,7 +40,6 @@ class Migration(migrations.Migration):
("require_authenticated", "Require Authenticated"), ("require_authenticated", "Require Authenticated"),
("require_unauthenticated", "Require Unauthenticated"), ("require_unauthenticated", "Require Unauthenticated"),
("require_superuser", "Require Superuser"), ("require_superuser", "Require Superuser"),
("require_redirect", "Require Redirect"),
("require_outpost", "Require Outpost"), ("require_outpost", "Require Outpost"),
], ],
default="none", default="none",

View File

@ -14,7 +14,6 @@ from structlog.stdlib import get_logger
from authentik.core.models import Token from authentik.core.models import Token
from authentik.core.types import UserSettingSerializer from authentik.core.types import UserSettingSerializer
from authentik.flows.challenge import FlowLayout from authentik.flows.challenge import FlowLayout
from authentik.lib.config import CONFIG
from authentik.lib.models import InheritanceForeignKey, SerializerModel from authentik.lib.models import InheritanceForeignKey, SerializerModel
from authentik.lib.utils.reflection import class_to_path from authentik.lib.utils.reflection import class_to_path
from authentik.policies.models import PolicyBindingModel from authentik.policies.models import PolicyBindingModel
@ -33,7 +32,6 @@ class FlowAuthenticationRequirement(models.TextChoices):
REQUIRE_AUTHENTICATED = "require_authenticated" REQUIRE_AUTHENTICATED = "require_authenticated"
REQUIRE_UNAUTHENTICATED = "require_unauthenticated" REQUIRE_UNAUTHENTICATED = "require_unauthenticated"
REQUIRE_SUPERUSER = "require_superuser" REQUIRE_SUPERUSER = "require_superuser"
REQUIRE_REDIRECT = "require_redirect"
REQUIRE_OUTPOST = "require_outpost" REQUIRE_OUTPOST = "require_outpost"
@ -179,13 +177,9 @@ class Flow(SerializerModel, PolicyBindingModel):
"""Get the URL to the background image. If the name is /static or starts with http """Get the URL to the background image. If the name is /static or starts with http
it is returned as-is""" it is returned as-is"""
if not self.background: if not self.background:
return ( return "/static/dist/assets/images/flow_background.jpg"
CONFIG.get("web.path", "/")[:-1] + "/static/dist/assets/images/flow_background.jpg" if self.background.name.startswith("http") or self.background.name.startswith("/static"):
)
if self.background.name.startswith("http"):
return self.background.name return self.background.name
if self.background.name.startswith("/static"):
return CONFIG.get("web.path", "/")[:-1] + self.background.name
return self.background.url return self.background.url
stages = models.ManyToManyField(Stage, through="FlowStageBinding", blank=True) stages = models.ManyToManyField(Stage, through="FlowStageBinding", blank=True)

View File

@ -1,10 +1,10 @@
"""Flows Planner""" """Flows Planner"""
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any from typing import Any
from django.core.cache import cache from django.core.cache import cache
from django.http import HttpRequest, HttpResponse from django.http import HttpRequest
from sentry_sdk import start_span from sentry_sdk import start_span
from sentry_sdk.tracing import Span from sentry_sdk.tracing import Span
from structlog.stdlib import BoundLogger, get_logger from structlog.stdlib import BoundLogger, get_logger
@ -23,15 +23,10 @@ from authentik.flows.models import (
in_memory_stage, in_memory_stage,
) )
from authentik.lib.config import CONFIG from authentik.lib.config import CONFIG
from authentik.lib.utils.urls import redirect_with_qs
from authentik.outposts.models import Outpost from authentik.outposts.models import Outpost
from authentik.policies.engine import PolicyEngine from authentik.policies.engine import PolicyEngine
from authentik.root.middleware import ClientIPMiddleware from authentik.root.middleware import ClientIPMiddleware
if TYPE_CHECKING:
from authentik.flows.stage import StageView
LOGGER = get_logger() LOGGER = get_logger()
PLAN_CONTEXT_PENDING_USER = "pending_user" PLAN_CONTEXT_PENDING_USER = "pending_user"
PLAN_CONTEXT_SSO = "is_sso" PLAN_CONTEXT_SSO = "is_sso"
@ -42,8 +37,6 @@ PLAN_CONTEXT_OUTPOST = "outpost"
# Is set by the Flow Planner when a FlowToken was used, and the currently active flow plan # Is set by the Flow Planner when a FlowToken was used, and the currently active flow plan
# was restored. # was restored.
PLAN_CONTEXT_IS_RESTORED = "is_restored" PLAN_CONTEXT_IS_RESTORED = "is_restored"
PLAN_CONTEXT_IS_REDIRECTED = "is_redirected"
PLAN_CONTEXT_REDIRECT_STAGE_TARGET = "redirect_stage_target"
CACHE_TIMEOUT = CONFIG.get_int("cache.timeout_flows") CACHE_TIMEOUT = CONFIG.get_int("cache.timeout_flows")
CACHE_PREFIX = "goauthentik.io/flows/planner/" CACHE_PREFIX = "goauthentik.io/flows/planner/"
@ -117,54 +110,6 @@ class FlowPlan:
"""Check if there are any stages left in this plan""" """Check if there are any stages left in this plan"""
return len(self.markers) + len(self.bindings) > 0 return len(self.markers) + len(self.bindings) > 0
def requires_flow_executor(
self,
allowed_silent_types: list["StageView"] | None = None,
):
# Check if we actually need to show the Flow executor, or if we can jump straight to the end
found_unskippable = True
if allowed_silent_types:
LOGGER.debug("Checking if we can skip the flow executor...")
# Policies applied to the flow have already been evaluated, so we're checking for stages
# allow-listed or bindings that require a policy re-eval
found_unskippable = False
for binding, marker in zip(self.bindings, self.markers, strict=True):
if binding.stage.view not in allowed_silent_types:
found_unskippable = True
if marker and isinstance(marker, ReevaluateMarker):
found_unskippable = True
LOGGER.debug("Required flow executor status", status=found_unskippable)
return found_unskippable
def to_redirect(
self,
request: HttpRequest,
flow: Flow,
allowed_silent_types: list["StageView"] | None = None,
) -> HttpResponse:
"""Redirect to the flow executor for this flow plan"""
from authentik.flows.views.executor import (
SESSION_KEY_PLAN,
FlowExecutorView,
)
request.session[SESSION_KEY_PLAN] = self
requires_flow_executor = self.requires_flow_executor(allowed_silent_types)
if not requires_flow_executor:
# No unskippable stages found, so we can directly return the response of the last stage
final_stage: type[StageView] = self.bindings[-1].stage.view
temp_exec = FlowExecutorView(flow=flow, request=request, plan=self)
temp_exec.current_stage = self.bindings[-1].stage
stage = final_stage(request=request, executor=temp_exec)
return stage.dispatch(request)
return redirect_with_qs(
"authentik_core:if-flow",
request.GET,
flow_slug=flow.slug,
)
class FlowPlanner: class FlowPlanner:
"""Execute all policies to plan out a flat list of all Stages """Execute all policies to plan out a flat list of all Stages
@ -183,7 +128,7 @@ class FlowPlanner:
self.flow = flow self.flow = flow
self._logger = get_logger().bind(flow_slug=flow.slug) self._logger = get_logger().bind(flow_slug=flow.slug)
def _check_authentication(self, request: HttpRequest, context: dict[str, Any]): def _check_authentication(self, request: HttpRequest):
"""Check the flow's authentication level is matched by `request`""" """Check the flow's authentication level is matched by `request`"""
if ( if (
self.flow.authentication == FlowAuthenticationRequirement.REQUIRE_AUTHENTICATED self.flow.authentication == FlowAuthenticationRequirement.REQUIRE_AUTHENTICATED
@ -200,11 +145,6 @@ class FlowPlanner:
and not request.user.is_superuser and not request.user.is_superuser
): ):
raise FlowNonApplicableException() raise FlowNonApplicableException()
if (
self.flow.authentication == FlowAuthenticationRequirement.REQUIRE_REDIRECT
and context.get(PLAN_CONTEXT_IS_REDIRECTED) is None
):
raise FlowNonApplicableException()
outpost_user = ClientIPMiddleware.get_outpost_user(request) outpost_user = ClientIPMiddleware.get_outpost_user(request)
if self.flow.authentication == FlowAuthenticationRequirement.REQUIRE_OUTPOST: if self.flow.authentication == FlowAuthenticationRequirement.REQUIRE_OUTPOST:
if not outpost_user: if not outpost_user:
@ -236,13 +176,18 @@ class FlowPlanner:
) )
context = default_context or {} context = default_context or {}
# Bit of a workaround here, if there is a pending user set in the default context # 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 # 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: if context and PLAN_CONTEXT_PENDING_USER in context:
user = context[PLAN_CONTEXT_PENDING_USER] user = context[PLAN_CONTEXT_PENDING_USER]
else: else:
user = request.user user = request.user
# We only need to check the flow authentication if it's planned without a user
context.update(self._check_authentication(request, context)) # in the context, as a user in the context can only be set via the explicit code API
# or if a flow is restarted due to `invalid_response_action` being set to
# `restart_with_context`, which can only happen if the user was already authorized
# to use the flow
context.update(self._check_authentication(request))
# First off, check the flow's direct policy bindings # First off, check the flow's direct policy bindings
# to make sure the user even has access to the flow # to make sure the user even has access to the flow
engine = PolicyEngine(self.flow, user, request) engine = PolicyEngine(self.flow, user, request)

View File

@ -2,7 +2,6 @@
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from django.conf import settings
from django.contrib.auth.models import AnonymousUser from django.contrib.auth.models import AnonymousUser
from django.http import HttpRequest from django.http import HttpRequest
from django.http.request import QueryDict from django.http.request import QueryDict
@ -93,11 +92,7 @@ class ChallengeStageView(StageView):
def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
"""Return a challenge for the frontend to solve""" """Return a challenge for the frontend to solve"""
try: challenge = self._get_challenge(*args, **kwargs)
challenge = self._get_challenge(*args, **kwargs)
except StageInvalidException as exc:
self.logger.debug("Got StageInvalidException", exc=exc)
return self.executor.stage_invalid()
if not challenge.is_valid(): if not challenge.is_valid():
self.logger.warning( self.logger.warning(
"f(ch): Invalid challenge", "f(ch): Invalid challenge",
@ -173,7 +168,11 @@ class ChallengeStageView(StageView):
stage_type=self.__class__.__name__, method="get_challenge" stage_type=self.__class__.__name__, method="get_challenge"
).time(), ).time(),
): ):
challenge = self.get_challenge(*args, **kwargs) try:
challenge = self.get_challenge(*args, **kwargs)
except StageInvalidException as exc:
self.logger.debug("Got StageInvalidException", exc=exc)
return self.executor.stage_invalid()
with start_span( with start_span(
op="authentik.flow.stage._get_challenge", op="authentik.flow.stage._get_challenge",
name=self.__class__.__name__, name=self.__class__.__name__,
@ -225,14 +224,6 @@ class ChallengeStageView(StageView):
full_errors[field].append(field_error) full_errors[field].append(field_error)
challenge_response.initial_data["response_errors"] = full_errors challenge_response.initial_data["response_errors"] = full_errors
if not challenge_response.is_valid(): if not challenge_response.is_valid():
if settings.TEST:
raise StageInvalidException(
(
f"Invalid challenge response: \n\t{challenge_response.errors}"
f"\n\nValidated data:\n\t {challenge_response.data}"
f"\n\nInitial data:\n\t {challenge_response.initial_data}"
),
)
self.logger.error( self.logger.error(
"f(ch): invalid challenge response", "f(ch): invalid challenge response",
errors=challenge_response.errors, errors=challenge_response.errors,

View File

@ -9,8 +9,8 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
<title>{% block title %}{% trans title|default:brand.branding_title %}{% endblock %}</title> <title>{% block title %}{% trans title|default:brand.branding_title %}{% endblock %}</title>
<link rel="icon" href="{{ brand.branding_favicon_url }}"> <link rel="icon" href="{{ brand.branding_favicon }}">
<link rel="shortcut icon" href="{{ brand.branding_favicon_url }}"> <link rel="shortcut icon" href="{{ brand.branding_favicon }}">
{% block head_before %} {% block head_before %}
{% endblock %} {% endblock %}
<link rel="stylesheet" type="text/css" href="{% static 'dist/sfe/bootstrap.min.css' %}"> <link rel="stylesheet" type="text/css" href="{% static 'dist/sfe/bootstrap.min.css' %}">

View File

@ -5,8 +5,6 @@ from unittest.mock import MagicMock, Mock, PropertyMock, patch
from django.contrib.auth.models import AnonymousUser from django.contrib.auth.models import AnonymousUser
from django.contrib.sessions.middleware import SessionMiddleware from django.contrib.sessions.middleware import SessionMiddleware
from django.core.cache import cache from django.core.cache import cache
from django.http import HttpRequest
from django.shortcuts import redirect
from django.test import RequestFactory, TestCase from django.test import RequestFactory, TestCase
from django.urls import reverse from django.urls import reverse
from guardian.shortcuts import get_anonymous_user from guardian.shortcuts import get_anonymous_user
@ -16,19 +14,8 @@ from authentik.core.models import User
from authentik.core.tests.utils import create_test_admin_user, create_test_flow from authentik.core.tests.utils import create_test_admin_user, create_test_flow
from authentik.flows.exceptions import EmptyFlowException, FlowNonApplicableException from authentik.flows.exceptions import EmptyFlowException, FlowNonApplicableException
from authentik.flows.markers import ReevaluateMarker, StageMarker from authentik.flows.markers import ReevaluateMarker, StageMarker
from authentik.flows.models import ( from authentik.flows.models import FlowAuthenticationRequirement, FlowDesignation, FlowStageBinding
FlowAuthenticationRequirement, from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlanner, cache_key
FlowDesignation,
FlowStageBinding,
in_memory_stage,
)
from authentik.flows.planner import (
PLAN_CONTEXT_IS_REDIRECTED,
PLAN_CONTEXT_PENDING_USER,
FlowPlanner,
cache_key,
)
from authentik.flows.stage import StageView
from authentik.lib.tests.utils import dummy_get_response from authentik.lib.tests.utils import dummy_get_response
from authentik.outposts.apps import MANAGED_OUTPOST from authentik.outposts.apps import MANAGED_OUTPOST
from authentik.outposts.models import Outpost from authentik.outposts.models import Outpost
@ -86,24 +73,6 @@ class TestFlowPlanner(TestCase):
planner.allow_empty_flows = True planner.allow_empty_flows = True
planner.plan(request) planner.plan(request)
def test_authentication_redirect_required(self):
"""Test flow authentication (redirect required)"""
flow = create_test_flow()
flow.authentication = FlowAuthenticationRequirement.REQUIRE_REDIRECT
request = self.request_factory.get(
reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}),
)
request.user = AnonymousUser()
planner = FlowPlanner(flow)
planner.allow_empty_flows = True
with self.assertRaises(FlowNonApplicableException):
planner.plan(request)
context = {}
context[PLAN_CONTEXT_IS_REDIRECTED] = create_test_flow()
planner.plan(request, context)
@reconcile_app("authentik_outposts") @reconcile_app("authentik_outposts")
def test_authentication_outpost(self): def test_authentication_outpost(self):
"""Test flow authentication (outpost)""" """Test flow authentication (outpost)"""
@ -242,99 +211,3 @@ class TestFlowPlanner(TestCase):
self.assertIsInstance(plan.markers[0], StageMarker) self.assertIsInstance(plan.markers[0], StageMarker)
self.assertIsInstance(plan.markers[1], ReevaluateMarker) self.assertIsInstance(plan.markers[1], ReevaluateMarker)
def test_to_redirect(self):
"""Test to_redirect and skipping the flow executor"""
flow = create_test_flow()
flow.authentication = FlowAuthenticationRequirement.NONE
request = self.request_factory.get(
reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}),
)
middleware = SessionMiddleware(dummy_get_response)
middleware.process_request(request)
request.session.save()
request.user = AnonymousUser()
planner = FlowPlanner(flow)
planner.allow_empty_flows = True
plan = planner.plan(request)
self.assertTrue(plan.requires_flow_executor())
self.assertEqual(
plan.to_redirect(request, flow).url,
reverse("authentik_core:if-flow", kwargs={"flow_slug": flow.slug}),
)
def test_to_redirect_skip_simple(self):
"""Test to_redirect and skipping the flow executor"""
flow = create_test_flow()
flow.authentication = FlowAuthenticationRequirement.NONE
request = self.request_factory.get(
reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}),
)
middleware = SessionMiddleware(dummy_get_response)
middleware.process_request(request)
request.session.save()
request.user = AnonymousUser()
planner = FlowPlanner(flow)
planner.allow_empty_flows = True
plan = planner.plan(request)
class TStageView(StageView):
def dispatch(self, request: HttpRequest, *args, **kwargs):
return redirect("https://authentik.company")
plan.append_stage(in_memory_stage(TStageView))
self.assertFalse(plan.requires_flow_executor(allowed_silent_types=[TStageView]))
self.assertEqual(
plan.to_redirect(request, flow, allowed_silent_types=[TStageView]).url,
"https://authentik.company",
)
def test_to_redirect_skip_stage(self):
"""Test to_redirect and skipping the flow executor
(with a stage bound that cannot be skipped)"""
flow = create_test_flow()
flow.authentication = FlowAuthenticationRequirement.NONE
FlowStageBinding.objects.create(
target=flow, stage=DummyStage.objects.create(name="dummy"), order=0
)
request = self.request_factory.get(
reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}),
)
request.user = AnonymousUser()
planner = FlowPlanner(flow)
planner.allow_empty_flows = True
plan = planner.plan(request)
class TStageView(StageView):
def dispatch(self, request: HttpRequest, *args, **kwargs):
return redirect("https://authentik.company")
plan.append_stage(in_memory_stage(TStageView))
self.assertTrue(plan.requires_flow_executor(allowed_silent_types=[TStageView]))
def test_to_redirect_skip_policies(self):
"""Test to_redirect and skipping the flow executor
(with a marker on the stage view type that can be skipped)
Note that this is not actually used anywhere in the code, all stages that are dynamically
added are statically added"""
flow = create_test_flow()
flow.authentication = FlowAuthenticationRequirement.NONE
request = self.request_factory.get(
reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}),
)
request.user = AnonymousUser()
planner = FlowPlanner(flow)
planner.allow_empty_flows = True
plan = planner.plan(request)
class TStageView(StageView):
def dispatch(self, request: HttpRequest, *args, **kwargs):
return redirect("https://authentik.company")
plan.append_stage(in_memory_stage(TStageView), ReevaluateMarker(None))
self.assertTrue(plan.requires_flow_executor(allowed_silent_types=[TStageView]))

View File

@ -171,8 +171,7 @@ class FlowExecutorView(APIView):
# Existing plan is deleted from session and instance # Existing plan is deleted from session and instance
self.plan = None self.plan = None
self.cancel() self.cancel()
else: self._logger.debug("f(exec): Continuing existing plan")
self._logger.debug("f(exec): Continuing existing plan")
# Initial flow request, check if we have an upstream query string passed in # Initial flow request, check if we have an upstream query string passed in
request.session[SESSION_KEY_GET] = get_params request.session[SESSION_KEY_GET] = get_params
@ -598,4 +597,9 @@ class ConfigureFlowInitView(LoginRequiredMixin, View):
except FlowNonApplicableException: except FlowNonApplicableException:
LOGGER.warning("Flow not applicable to user") LOGGER.warning("Flow not applicable to user")
raise Http404 from None raise Http404 from None
return plan.to_redirect(request, stage.configure_flow) request.session[SESSION_KEY_PLAN] = plan
return redirect_with_qs(
"authentik_core:if-flow",
self.request.GET,
flow_slug=stage.configure_flow.slug,
)

View File

@ -5,7 +5,6 @@ import json
import os import os
from collections.abc import Mapping from collections.abc import Mapping
from contextlib import contextmanager from contextlib import contextmanager
from copy import deepcopy
from dataclasses import dataclass, field from dataclasses import dataclass, field
from enum import Enum from enum import Enum
from glob import glob from glob import glob
@ -280,24 +279,9 @@ class ConfigLoader:
self.log("warning", "Failed to parse config as int", path=path, exc=str(exc)) self.log("warning", "Failed to parse config as int", path=path, exc=str(exc))
return default return default
def get_optional_int(self, path: str, default=None) -> int | None:
"""Wrapper for get that converts value into int or None if set"""
value = self.get(path, default)
try:
return int(value)
except (ValueError, TypeError) as exc:
if value is None or (isinstance(value, str) and value.lower() == "null"):
return None
self.log("warning", "Failed to parse config as int", path=path, exc=str(exc))
return default
def get_bool(self, path: str, default=False) -> bool: def get_bool(self, path: str, default=False) -> bool:
"""Wrapper for get that converts value into boolean""" """Wrapper for get that converts value into boolean"""
value = self.get(path, UNSET) return str(self.get(path, default)).lower() == "true"
if value is UNSET:
return default
return str(self.get(path)).lower() == "true"
def get_keys(self, path: str, sep=".") -> list[str]: def get_keys(self, path: str, sep=".") -> list[str]:
"""List attribute keys by using yaml path""" """List attribute keys by using yaml path"""
@ -352,71 +336,6 @@ def redis_url(db: int) -> str:
return _redis_url return _redis_url
def django_db_config(config: ConfigLoader | None = None) -> dict:
if not config:
config = CONFIG
db = {
"default": {
"ENGINE": "authentik.root.db",
"HOST": config.get("postgresql.host"),
"NAME": config.get("postgresql.name"),
"USER": config.get("postgresql.user"),
"PASSWORD": config.get("postgresql.password"),
"PORT": config.get("postgresql.port"),
"OPTIONS": {
"sslmode": config.get("postgresql.sslmode"),
"sslrootcert": config.get("postgresql.sslrootcert"),
"sslcert": config.get("postgresql.sslcert"),
"sslkey": config.get("postgresql.sslkey"),
},
"CONN_MAX_AGE": CONFIG.get_optional_int("postgresql.conn_max_age", 0),
"CONN_HEALTH_CHECKS": CONFIG.get_bool("postgresql.conn_health_checks", False),
"DISABLE_SERVER_SIDE_CURSORS": CONFIG.get_bool(
"postgresql.disable_server_side_cursors", False
),
"TEST": {
"NAME": config.get("postgresql.test.name"),
},
}
}
conn_max_age = CONFIG.get_optional_int("postgresql.conn_max_age", UNSET)
disable_server_side_cursors = CONFIG.get_bool("postgresql.disable_server_side_cursors", UNSET)
if config.get_bool("postgresql.use_pgpool", False):
db["default"]["DISABLE_SERVER_SIDE_CURSORS"] = True
if disable_server_side_cursors is not UNSET:
db["default"]["DISABLE_SERVER_SIDE_CURSORS"] = disable_server_side_cursors
if config.get_bool("postgresql.use_pgbouncer", False):
# https://docs.djangoproject.com/en/4.0/ref/databases/#transaction-pooling-server-side-cursors
db["default"]["DISABLE_SERVER_SIDE_CURSORS"] = True
# https://docs.djangoproject.com/en/4.0/ref/databases/#persistent-connections
db["default"]["CONN_MAX_AGE"] = None # persistent
if disable_server_side_cursors is not UNSET:
db["default"]["DISABLE_SERVER_SIDE_CURSORS"] = disable_server_side_cursors
if conn_max_age is not UNSET:
db["default"]["CONN_MAX_AGE"] = conn_max_age
for replica in config.get_keys("postgresql.read_replicas"):
_database = deepcopy(db["default"])
for setting, current_value in db["default"].items():
if isinstance(current_value, dict):
continue
override = config.get(
f"postgresql.read_replicas.{replica}.{setting.lower()}", default=UNSET
)
if override is not UNSET:
_database[setting] = override
for setting in db["default"]["OPTIONS"].keys():
override = config.get(
f"postgresql.read_replicas.{replica}.{setting.lower()}", default=UNSET
)
if override is not UNSET:
_database["OPTIONS"][setting] = override
db[f"replica_{replica}"] = _database
return db
if __name__ == "__main__": if __name__ == "__main__":
if len(argv) < 2: # noqa: PLR2004 if len(argv) < 2: # noqa: PLR2004
print(dumps(CONFIG.raw, indent=4, cls=AttrEncoder)) print(dumps(CONFIG.raw, indent=4, cls=AttrEncoder))

View File

@ -6,6 +6,8 @@ postgresql:
user: authentik user: authentik
port: 5432 port: 5432
password: "env://POSTGRES_PASSWORD" password: "env://POSTGRES_PASSWORD"
use_pgbouncer: false
use_pgpool: false
test: test:
name: test_authentik name: test_authentik
read_replicas: {} read_replicas: {}
@ -133,7 +135,6 @@ web:
# No default here as it's set dynamically # No default here as it's set dynamically
# workers: 2 # workers: 2
threads: 4 threads: 4
path: /
worker: worker:
concurrency: 2 concurrency: 2

View File

@ -36,7 +36,6 @@ from authentik.lib.utils.http import authentik_user_agent
from authentik.lib.utils.reflection import get_env from authentik.lib.utils.reflection import get_env
LOGGER = get_logger() LOGGER = get_logger()
_root_path = CONFIG.get("web.path", "/")
class SentryIgnoredException(Exception): class SentryIgnoredException(Exception):
@ -91,7 +90,7 @@ def traces_sampler(sampling_context: dict) -> float:
path = sampling_context.get("asgi_scope", {}).get("path", "") path = sampling_context.get("asgi_scope", {}).get("path", "")
_type = sampling_context.get("asgi_scope", {}).get("type", "") _type = sampling_context.get("asgi_scope", {}).get("type", "")
# Ignore all healthcheck routes # Ignore all healthcheck routes
if path.startswith(f"{_root_path}-/health") or path.startswith(f"{_root_path}-/metrics"): if path.startswith("/-/health") or path.startswith("/-/metrics"):
return 0 return 0
if _type == "websocket": if _type == "websocket":
return 0 return 0

View File

@ -82,7 +82,7 @@ class SyncTasks:
return return
try: try:
for page in users_paginator.page_range: for page in users_paginator.page_range:
messages.append(_("Syncing page {page} of users".format(page=page))) messages.append(_("Syncing page %(page)d of users" % {"page": page}))
for msg in sync_objects.apply_async( for msg in sync_objects.apply_async(
args=(class_to_path(User), page, provider_pk), args=(class_to_path(User), page, provider_pk),
time_limit=PAGE_TIMEOUT, time_limit=PAGE_TIMEOUT,
@ -90,7 +90,7 @@ class SyncTasks:
).get(): ).get():
messages.append(LogEvent(**msg)) messages.append(LogEvent(**msg))
for page in groups_paginator.page_range: for page in groups_paginator.page_range:
messages.append(_("Syncing page {page} of groups".format(page=page))) messages.append(_("Syncing page %(page)d of groups" % {"page": page}))
for msg in sync_objects.apply_async( for msg in sync_objects.apply_async(
args=(class_to_path(Group), page, provider_pk), args=(class_to_path(Group), page, provider_pk),
time_limit=PAGE_TIMEOUT, time_limit=PAGE_TIMEOUT,

View File

@ -9,14 +9,7 @@ from unittest import mock
from django.conf import ImproperlyConfigured from django.conf import ImproperlyConfigured
from django.test import TestCase from django.test import TestCase
from authentik.lib.config import ( from authentik.lib.config import ENV_PREFIX, UNSET, Attr, AttrEncoder, ConfigLoader
ENV_PREFIX,
UNSET,
Attr,
AttrEncoder,
ConfigLoader,
django_db_config,
)
class TestConfig(TestCase): class TestConfig(TestCase):
@ -182,283 +175,3 @@ class TestConfig(TestCase):
config = ConfigLoader() config = ConfigLoader()
config.set("foo.bar", "baz") config.set("foo.bar", "baz")
self.assertEqual(list(config.get_keys("foo")), ["bar"]) self.assertEqual(list(config.get_keys("foo")), ["bar"])
def test_db_default(self):
"""Test default DB Config"""
config = ConfigLoader()
config.set("postgresql.host", "foo")
config.set("postgresql.name", "foo")
config.set("postgresql.user", "foo")
config.set("postgresql.password", "foo")
config.set("postgresql.port", "foo")
config.set("postgresql.sslmode", "foo")
config.set("postgresql.sslrootcert", "foo")
config.set("postgresql.sslcert", "foo")
config.set("postgresql.sslkey", "foo")
config.set("postgresql.test.name", "foo")
conf = django_db_config(config)
self.assertEqual(
conf,
{
"default": {
"ENGINE": "authentik.root.db",
"HOST": "foo",
"NAME": "foo",
"OPTIONS": {
"sslcert": "foo",
"sslkey": "foo",
"sslmode": "foo",
"sslrootcert": "foo",
},
"PASSWORD": "foo",
"PORT": "foo",
"TEST": {"NAME": "foo"},
"USER": "foo",
"CONN_MAX_AGE": 0,
"CONN_HEALTH_CHECKS": False,
"DISABLE_SERVER_SIDE_CURSORS": False,
}
},
)
def test_db_read_replicas(self):
"""Test read replicas"""
config = ConfigLoader()
config.set("postgresql.host", "foo")
config.set("postgresql.name", "foo")
config.set("postgresql.user", "foo")
config.set("postgresql.password", "foo")
config.set("postgresql.port", "foo")
config.set("postgresql.sslmode", "foo")
config.set("postgresql.sslrootcert", "foo")
config.set("postgresql.sslcert", "foo")
config.set("postgresql.sslkey", "foo")
config.set("postgresql.test.name", "foo")
# Read replica
config.set("postgresql.read_replicas.0.host", "bar")
conf = django_db_config(config)
self.assertEqual(
conf,
{
"default": {
"ENGINE": "authentik.root.db",
"HOST": "foo",
"NAME": "foo",
"OPTIONS": {
"sslcert": "foo",
"sslkey": "foo",
"sslmode": "foo",
"sslrootcert": "foo",
},
"PASSWORD": "foo",
"PORT": "foo",
"TEST": {"NAME": "foo"},
"USER": "foo",
"CONN_MAX_AGE": 0,
"CONN_HEALTH_CHECKS": False,
"DISABLE_SERVER_SIDE_CURSORS": False,
},
"replica_0": {
"ENGINE": "authentik.root.db",
"HOST": "bar",
"NAME": "foo",
"OPTIONS": {
"sslcert": "foo",
"sslkey": "foo",
"sslmode": "foo",
"sslrootcert": "foo",
},
"PASSWORD": "foo",
"PORT": "foo",
"TEST": {"NAME": "foo"},
"USER": "foo",
"CONN_MAX_AGE": 0,
"CONN_HEALTH_CHECKS": False,
"DISABLE_SERVER_SIDE_CURSORS": False,
},
},
)
def test_db_read_replicas_pgbouncer(self):
"""Test read replicas"""
config = ConfigLoader()
config.set("postgresql.host", "foo")
config.set("postgresql.name", "foo")
config.set("postgresql.user", "foo")
config.set("postgresql.password", "foo")
config.set("postgresql.port", "foo")
config.set("postgresql.sslmode", "foo")
config.set("postgresql.sslrootcert", "foo")
config.set("postgresql.sslcert", "foo")
config.set("postgresql.sslkey", "foo")
config.set("postgresql.test.name", "foo")
config.set("postgresql.use_pgbouncer", True)
# Read replica
config.set("postgresql.read_replicas.0.host", "bar")
# Override conn_max_age
config.set("postgresql.read_replicas.0.conn_max_age", 10)
# This isn't supported
config.set("postgresql.read_replicas.0.use_pgbouncer", False)
conf = django_db_config(config)
self.assertEqual(
conf,
{
"default": {
"DISABLE_SERVER_SIDE_CURSORS": True,
"CONN_MAX_AGE": None,
"CONN_HEALTH_CHECKS": False,
"ENGINE": "authentik.root.db",
"HOST": "foo",
"NAME": "foo",
"OPTIONS": {
"sslcert": "foo",
"sslkey": "foo",
"sslmode": "foo",
"sslrootcert": "foo",
},
"PASSWORD": "foo",
"PORT": "foo",
"TEST": {"NAME": "foo"},
"USER": "foo",
},
"replica_0": {
"DISABLE_SERVER_SIDE_CURSORS": True,
"CONN_MAX_AGE": 10,
"CONN_HEALTH_CHECKS": False,
"ENGINE": "authentik.root.db",
"HOST": "bar",
"NAME": "foo",
"OPTIONS": {
"sslcert": "foo",
"sslkey": "foo",
"sslmode": "foo",
"sslrootcert": "foo",
},
"PASSWORD": "foo",
"PORT": "foo",
"TEST": {"NAME": "foo"},
"USER": "foo",
},
},
)
def test_db_read_replicas_pgpool(self):
"""Test read replicas"""
config = ConfigLoader()
config.set("postgresql.host", "foo")
config.set("postgresql.name", "foo")
config.set("postgresql.user", "foo")
config.set("postgresql.password", "foo")
config.set("postgresql.port", "foo")
config.set("postgresql.sslmode", "foo")
config.set("postgresql.sslrootcert", "foo")
config.set("postgresql.sslcert", "foo")
config.set("postgresql.sslkey", "foo")
config.set("postgresql.test.name", "foo")
config.set("postgresql.use_pgpool", True)
# Read replica
config.set("postgresql.read_replicas.0.host", "bar")
# This isn't supported
config.set("postgresql.read_replicas.0.use_pgpool", False)
conf = django_db_config(config)
self.assertEqual(
conf,
{
"default": {
"DISABLE_SERVER_SIDE_CURSORS": True,
"CONN_MAX_AGE": 0,
"CONN_HEALTH_CHECKS": False,
"ENGINE": "authentik.root.db",
"HOST": "foo",
"NAME": "foo",
"OPTIONS": {
"sslcert": "foo",
"sslkey": "foo",
"sslmode": "foo",
"sslrootcert": "foo",
},
"PASSWORD": "foo",
"PORT": "foo",
"TEST": {"NAME": "foo"},
"USER": "foo",
},
"replica_0": {
"DISABLE_SERVER_SIDE_CURSORS": True,
"CONN_MAX_AGE": 0,
"CONN_HEALTH_CHECKS": False,
"ENGINE": "authentik.root.db",
"HOST": "bar",
"NAME": "foo",
"OPTIONS": {
"sslcert": "foo",
"sslkey": "foo",
"sslmode": "foo",
"sslrootcert": "foo",
},
"PASSWORD": "foo",
"PORT": "foo",
"TEST": {"NAME": "foo"},
"USER": "foo",
},
},
)
def test_db_read_replicas_diff_ssl(self):
"""Test read replicas (with different SSL Settings)"""
"""Test read replicas"""
config = ConfigLoader()
config.set("postgresql.host", "foo")
config.set("postgresql.name", "foo")
config.set("postgresql.user", "foo")
config.set("postgresql.password", "foo")
config.set("postgresql.port", "foo")
config.set("postgresql.sslmode", "foo")
config.set("postgresql.sslrootcert", "foo")
config.set("postgresql.sslcert", "foo")
config.set("postgresql.sslkey", "foo")
config.set("postgresql.test.name", "foo")
# Read replica
config.set("postgresql.read_replicas.0.host", "bar")
config.set("postgresql.read_replicas.0.sslcert", "bar")
conf = django_db_config(config)
self.assertEqual(
conf,
{
"default": {
"ENGINE": "authentik.root.db",
"HOST": "foo",
"NAME": "foo",
"OPTIONS": {
"sslcert": "foo",
"sslkey": "foo",
"sslmode": "foo",
"sslrootcert": "foo",
},
"PASSWORD": "foo",
"PORT": "foo",
"TEST": {"NAME": "foo"},
"USER": "foo",
"DISABLE_SERVER_SIDE_CURSORS": False,
"CONN_MAX_AGE": 0,
"CONN_HEALTH_CHECKS": False,
},
"replica_0": {
"ENGINE": "authentik.root.db",
"HOST": "bar",
"NAME": "foo",
"OPTIONS": {
"sslcert": "bar",
"sslkey": "foo",
"sslmode": "foo",
"sslrootcert": "foo",
},
"PASSWORD": "foo",
"PORT": "foo",
"TEST": {"NAME": "foo"},
"USER": "foo",
"DISABLE_SERVER_SIDE_CURSORS": False,
"CONN_MAX_AGE": 0,
"CONN_HEALTH_CHECKS": False,
},
},
)

View File

@ -1,22 +0,0 @@
"""authentik database utilities"""
import gc
from django.db.models import QuerySet
def qs_batch_iter(qs: QuerySet, batch_size: int = 10_000, gc_collect: bool = True):
pk_iter = qs.values_list("pk", flat=True).order_by("pk").distinct().iterator()
eof = False
while not eof:
pk_buffer = []
i = 0
try:
while i < batch_size:
pk_buffer.append(pk_iter.next())
i += 1
except StopIteration:
eof = True
yield from qs.filter(pk__in=pk_buffer).order_by("pk").iterator()
if gc_collect:
gc.collect()

View File

@ -84,17 +84,19 @@ class PolicyBindingSerializer(ModelSerializer):
def validate(self, attrs: OrderedDict) -> OrderedDict: def validate(self, attrs: OrderedDict) -> OrderedDict:
"""Check that either policy, group or user is set.""" """Check that either policy, group or user is set."""
target: PolicyBindingModel = attrs.get("target") count = sum(
supported = target.supported_policy_binding_targets() [
supported.sort() bool(attrs.get("policy", None)),
count = sum([bool(attrs.get(x, None)) for x in supported]) bool(attrs.get("group", None)),
bool(attrs.get("user", None)),
]
)
invalid = count > 1 invalid = count > 1
empty = count < 1 empty = count < 1
warning = ", ".join(f"'{x}'" for x in supported)
if invalid: if invalid:
raise ValidationError(f"Only one of {warning} can be set.") raise ValidationError("Only one of 'policy', 'group' or 'user' can be set.")
if empty: if empty:
raise ValidationError(f"One of {warning} must be set.") raise ValidationError("One of 'policy', 'group' or 'user' must be set.")
return attrs return attrs

View File

@ -43,9 +43,8 @@ class PasswordExpiryPolicy(Policy):
request.user.set_unusable_password() request.user.set_unusable_password()
request.user.save() request.user.save()
message = _( message = _(
"Password expired {days} days ago. Please update your password.".format( "Password expired %(days)d days ago. Please update your password."
days=days_since_expiry % {"days": days_since_expiry}
)
) )
return PolicyResult(False, message) return PolicyResult(False, message)
return PolicyResult(False, _("Password has expired.")) return PolicyResult(False, _("Password has expired."))

View File

@ -1,6 +1,4 @@
# Generated by Django 4.2.5 on 2023-09-13 18:07 # Generated by Django 4.2.5 on 2023-09-13 18:07
import authentik.lib.models
import django.db.models.deletion
from django.db import migrations, models from django.db import migrations, models
@ -25,13 +23,4 @@ class Migration(migrations.Migration):
default=30, help_text="Timeout after which Policy execution is terminated." default=30, help_text="Timeout after which Policy execution is terminated."
), ),
), ),
migrations.AlterField(
model_name="policybinding",
name="target",
field=authentik.lib.models.InheritanceForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="bindings",
to="authentik_policies.policybindingmodel",
),
),
] ]

View File

@ -47,10 +47,6 @@ class PolicyBindingModel(models.Model):
def __str__(self) -> str: def __str__(self) -> str:
return f"PolicyBindingModel {self.pbm_uuid}" return f"PolicyBindingModel {self.pbm_uuid}"
def supported_policy_binding_targets(self):
"""Return the list of objects that can be bound to this object."""
return ["policy", "user", "group"]
class PolicyBinding(SerializerModel): class PolicyBinding(SerializerModel):
"""Relationship between a Policy and a PolicyBindingModel.""" """Relationship between a Policy and a PolicyBindingModel."""
@ -85,9 +81,7 @@ class PolicyBinding(SerializerModel):
blank=True, blank=True,
) )
target = InheritanceForeignKey( target = InheritanceForeignKey(PolicyBindingModel, on_delete=models.CASCADE, related_name="+")
PolicyBindingModel, on_delete=models.CASCADE, related_name="bindings"
)
negate = models.BooleanField( negate = models.BooleanField(
default=False, default=False,
help_text=_("Negates the outcome of the policy. Messages are unaffected."), help_text=_("Negates the outcome of the policy. Messages are unaffected."),

View File

@ -135,7 +135,7 @@ class PasswordPolicy(Policy):
LOGGER.debug("got hibp result", count=final_count, hash=pw_hash[:5]) LOGGER.debug("got hibp result", count=final_count, hash=pw_hash[:5])
if final_count > self.hibp_allowed_count: if final_count > self.hibp_allowed_count:
LOGGER.debug("password failed", check="hibp", count=final_count) LOGGER.debug("password failed", check="hibp", count=final_count)
message = _("Password exists on {count} online lists.".format(count=final_count)) message = _("Password exists on %(count)d online lists." % {"count": final_count})
return PolicyResult(False, message) return PolicyResult(False, message)
return PolicyResult(True) return PolicyResult(True)

View File

@ -38,7 +38,7 @@ class TestBindingsAPI(APITestCase):
) )
self.assertJSONEqual( self.assertJSONEqual(
response.content.decode(), response.content.decode(),
{"non_field_errors": ["Only one of 'group', 'policy', 'user' can be set."]}, {"non_field_errors": ["Only one of 'policy', 'group' or 'user' can be set."]},
) )
def test_invalid_too_little(self): def test_invalid_too_little(self):
@ -49,5 +49,5 @@ class TestBindingsAPI(APITestCase):
) )
self.assertJSONEqual( self.assertJSONEqual(
response.content.decode(), response.content.decode(),
{"non_field_errors": ["One of 'group', 'policy', 'user' must be set."]}, {"non_field_errors": ["One of 'policy', 'group' or 'user' must be set."]},
) )

View File

@ -73,8 +73,7 @@ class OAuth2ProviderSerializer(ProviderSerializer):
"sub_mode", "sub_mode",
"property_mappings", "property_mappings",
"issuer_mode", "issuer_mode",
"jwt_federation_sources", "jwks_sources",
"jwt_federation_providers",
] ]
extra_kwargs = ProviderSerializer.Meta.extra_kwargs extra_kwargs = ProviderSerializer.Meta.extra_kwargs

View File

@ -1,25 +0,0 @@
# Generated by Django 5.0.9 on 2024-11-22 14:25
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("authentik_providers_oauth2", "0024_remove_oauth2provider_redirect_uris_and_more"),
]
operations = [
migrations.RenameField(
model_name="oauth2provider",
old_name="jwks_sources",
new_name="jwt_federation_sources",
),
migrations.AddField(
model_name="oauth2provider",
name="jwt_federation_providers",
field=models.ManyToManyField(
blank=True, default=None, to="authentik_providers_oauth2.oauth2provider"
),
),
]

View File

@ -1,38 +0,0 @@
# Generated by Django 5.0.10 on 2024-12-12 17:16
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("authentik_core", "0040_provider_invalidation_flow"),
(
"authentik_providers_oauth2",
"0025_rename_jwks_sources_oauth2provider_jwt_federation_sources_and_more",
),
]
operations = [
migrations.AlterField(
model_name="accesstoken",
name="session",
field=models.ForeignKey(
default=None,
null=True,
on_delete=django.db.models.deletion.CASCADE,
to="authentik_core.authenticatedsession",
),
),
migrations.AlterField(
model_name="authorizationcode",
name="session",
field=models.ForeignKey(
default=None,
null=True,
on_delete=django.db.models.deletion.CASCADE,
to="authentik_core.authenticatedsession",
),
),
]

View File

@ -244,7 +244,7 @@ class OAuth2Provider(WebfingerProvider, Provider):
related_name="oauth2provider_encryption_key_set", related_name="oauth2provider_encryption_key_set",
) )
jwt_federation_sources = models.ManyToManyField( jwks_sources = models.ManyToManyField(
OAuthSource, OAuthSource,
verbose_name=_( verbose_name=_(
"Any JWT signed by the JWK of the selected source can be used to authenticate." "Any JWT signed by the JWK of the selected source can be used to authenticate."
@ -253,7 +253,6 @@ class OAuth2Provider(WebfingerProvider, Provider):
default=None, default=None,
blank=True, blank=True,
) )
jwt_federation_providers = models.ManyToManyField("OAuth2Provider", blank=True, default=None)
@cached_property @cached_property
def jwt_key(self) -> tuple[str | PrivateKeyTypes, str]: def jwt_key(self) -> tuple[str | PrivateKeyTypes, str]:
@ -396,7 +395,7 @@ class BaseGrantModel(models.Model):
_scope = models.TextField(default="", verbose_name=_("Scopes")) _scope = models.TextField(default="", verbose_name=_("Scopes"))
auth_time = models.DateTimeField(verbose_name="Authentication time") auth_time = models.DateTimeField(verbose_name="Authentication time")
session = models.ForeignKey( session = models.ForeignKey(
AuthenticatedSession, null=True, on_delete=models.CASCADE, default=None AuthenticatedSession, null=True, on_delete=models.SET_DEFAULT, default=None
) )
class Meta: class Meta:
@ -497,11 +496,6 @@ class RefreshToken(SerializerModel, ExpiringModel, BaseGrantModel):
token = models.TextField(default=generate_client_secret) token = models.TextField(default=generate_client_secret)
_id_token = models.TextField(verbose_name=_("ID Token")) _id_token = models.TextField(verbose_name=_("ID Token"))
# Shadow the `session` field from `BaseGrantModel` as we want refresh tokens to persist even
# when the session is terminated.
session = models.ForeignKey(
AuthenticatedSession, null=True, on_delete=models.SET_DEFAULT, default=None
)
class Meta: class Meta:
indexes = [ indexes = [

View File

@ -311,7 +311,7 @@ class TestAuthorize(OAuthTestCase):
user = create_test_admin_user() user = create_test_admin_user()
self.client.force_login(user) self.client.force_login(user)
# Step 1, initiate params and get redirect to flow # Step 1, initiate params and get redirect to flow
response = self.client.get( self.client.get(
reverse("authentik_providers_oauth2:authorize"), reverse("authentik_providers_oauth2:authorize"),
data={ data={
"response_type": "code", "response_type": "code",
@ -320,10 +320,16 @@ class TestAuthorize(OAuthTestCase):
"redirect_uri": "foo://localhost", "redirect_uri": "foo://localhost",
}, },
) )
response = self.client.get(
reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}),
)
code: AuthorizationCode = AuthorizationCode.objects.filter(user=user).first() code: AuthorizationCode = AuthorizationCode.objects.filter(user=user).first()
self.assertEqual( self.assertJSONEqual(
response.url, response.content.decode(),
f"foo://localhost?code={code.code}&state={state}", {
"component": "xak-flow-redirect",
"to": f"foo://localhost?code={code.code}&state={state}",
},
) )
self.assertAlmostEqual( self.assertAlmostEqual(
code.expires.timestamp() - now().timestamp(), code.expires.timestamp() - now().timestamp(),
@ -371,7 +377,7 @@ class TestAuthorize(OAuthTestCase):
), ),
): ):
# Step 1, initiate params and get redirect to flow # Step 1, initiate params and get redirect to flow
response = self.client.get( self.client.get(
reverse("authentik_providers_oauth2:authorize"), reverse("authentik_providers_oauth2:authorize"),
data={ data={
"response_type": "id_token", "response_type": "id_token",
@ -382,16 +388,22 @@ class TestAuthorize(OAuthTestCase):
"nonce": generate_id(), "nonce": generate_id(),
}, },
) )
response = self.client.get(
reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}),
)
token: AccessToken = AccessToken.objects.filter(user=user).first() token: AccessToken = AccessToken.objects.filter(user=user).first()
expires = timedelta_from_string(provider.access_token_validity).total_seconds() expires = timedelta_from_string(provider.access_token_validity).total_seconds()
self.assertEqual( self.assertJSONEqual(
response.url, response.content.decode(),
( {
f"http://localhost#access_token={token.token}" "component": "xak-flow-redirect",
f"&id_token={provider.encode(token.id_token.to_dict())}" "to": (
f"&token_type={TOKEN_TYPE}" f"http://localhost#access_token={token.token}"
f"&expires_in={int(expires)}&state={state}" f"&id_token={provider.encode(token.id_token.to_dict())}"
), f"&token_type={TOKEN_TYPE}"
f"&expires_in={int(expires)}&state={state}"
),
},
) )
jwt = self.validate_jwt(token, provider) jwt = self.validate_jwt(token, provider)
self.assertEqual(jwt["amr"], ["pwd"]) self.assertEqual(jwt["amr"], ["pwd"])
@ -443,7 +455,7 @@ class TestAuthorize(OAuthTestCase):
), ),
): ):
# Step 1, initiate params and get redirect to flow # Step 1, initiate params and get redirect to flow
response = self.client.get( self.client.get(
reverse("authentik_providers_oauth2:authorize"), reverse("authentik_providers_oauth2:authorize"),
data={ data={
"response_type": "id_token", "response_type": "id_token",
@ -454,7 +466,10 @@ class TestAuthorize(OAuthTestCase):
"nonce": generate_id(), "nonce": generate_id(),
}, },
) )
self.assertEqual(response.status_code, 302) response = self.client.get(
reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}),
)
self.assertEqual(response.status_code, 200)
token: AccessToken = AccessToken.objects.filter(user=user).first() token: AccessToken = AccessToken.objects.filter(user=user).first()
expires = timedelta_from_string(provider.access_token_validity).total_seconds() expires = timedelta_from_string(provider.access_token_validity).total_seconds()
jwt = self.validate_jwe(token, provider) jwt = self.validate_jwe(token, provider)
@ -491,7 +506,7 @@ class TestAuthorize(OAuthTestCase):
), ),
): ):
# Step 1, initiate params and get redirect to flow # Step 1, initiate params and get redirect to flow
response = self.client.get( self.client.get(
reverse("authentik_providers_oauth2:authorize"), reverse("authentik_providers_oauth2:authorize"),
data={ data={
"response_type": "code", "response_type": "code",
@ -503,10 +518,16 @@ class TestAuthorize(OAuthTestCase):
"nonce": generate_id(), "nonce": generate_id(),
}, },
) )
response = self.client.get(
reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}),
)
code: AuthorizationCode = AuthorizationCode.objects.filter(user=user).first() code: AuthorizationCode = AuthorizationCode.objects.filter(user=user).first()
self.assertEqual( self.assertJSONEqual(
response.url, response.content.decode(),
f"http://localhost#code={code.code}&state={state}", {
"component": "xak-flow-redirect",
"to": (f"http://localhost#code={code.code}" f"&state={state}"),
},
) )
self.assertAlmostEqual( self.assertAlmostEqual(
code.expires.timestamp() - now().timestamp(), code.expires.timestamp() - now().timestamp(),

View File

@ -1,228 +0,0 @@
"""Test token view"""
from datetime import datetime, timedelta
from json import loads
from django.test import RequestFactory
from django.urls import reverse
from django.utils.timezone import now
from jwt import decode
from authentik.blueprints.tests import apply_blueprint
from authentik.core.models import Application, Group
from authentik.core.tests.utils import create_test_cert, create_test_flow, create_test_user
from authentik.lib.generators import generate_id
from authentik.policies.models import PolicyBinding
from authentik.providers.oauth2.constants import (
GRANT_TYPE_CLIENT_CREDENTIALS,
SCOPE_OPENID,
SCOPE_OPENID_EMAIL,
SCOPE_OPENID_PROFILE,
TOKEN_TYPE,
)
from authentik.providers.oauth2.models import (
AccessToken,
OAuth2Provider,
RedirectURI,
RedirectURIMatchingMode,
ScopeMapping,
)
from authentik.providers.oauth2.tests.utils import OAuthTestCase
class TestTokenClientCredentialsJWTProvider(OAuthTestCase):
"""Test token (client_credentials, with JWT) view"""
@apply_blueprint("system/providers-oauth2.yaml")
def setUp(self) -> None:
super().setUp()
self.factory = RequestFactory()
self.other_cert = create_test_cert()
self.cert = create_test_cert()
self.other_provider = OAuth2Provider.objects.create(
name=generate_id(),
authorization_flow=create_test_flow(),
signing_key=self.other_cert,
)
self.other_provider.property_mappings.set(ScopeMapping.objects.all())
self.app = Application.objects.create(
name=generate_id(), slug=generate_id(), provider=self.other_provider
)
self.provider: OAuth2Provider = OAuth2Provider.objects.create(
name="test",
authorization_flow=create_test_flow(),
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://testserver")],
signing_key=self.cert,
)
self.provider.jwt_federation_providers.add(self.other_provider)
self.provider.property_mappings.set(ScopeMapping.objects.all())
self.app = Application.objects.create(name="test", slug="test", provider=self.provider)
def test_invalid_type(self):
"""test invalid type"""
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}",
"client_id": self.provider.client_id,
"client_assertion_type": "foo",
"client_assertion": "foo.bar",
},
)
self.assertEqual(response.status_code, 400)
body = loads(response.content.decode())
self.assertEqual(body["error"], "invalid_grant")
def test_invalid_jwt(self):
"""test invalid JWT"""
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}",
"client_id": self.provider.client_id,
"client_assertion_type": "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
"client_assertion": "foo.bar",
},
)
self.assertEqual(response.status_code, 400)
body = loads(response.content.decode())
self.assertEqual(body["error"], "invalid_grant")
def test_invalid_signature(self):
"""test invalid JWT"""
token = self.provider.encode(
{
"sub": "foo",
"exp": datetime.now() + timedelta(hours=2),
}
)
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}",
"client_id": self.provider.client_id,
"client_assertion_type": "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
"client_assertion": token + "foo",
},
)
self.assertEqual(response.status_code, 400)
body = loads(response.content.decode())
self.assertEqual(body["error"], "invalid_grant")
def test_invalid_expired(self):
"""test invalid JWT"""
token = self.provider.encode(
{
"sub": "foo",
"exp": datetime.now() - timedelta(hours=2),
}
)
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}",
"client_id": self.provider.client_id,
"client_assertion_type": "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
"client_assertion": token,
},
)
self.assertEqual(response.status_code, 400)
body = loads(response.content.decode())
self.assertEqual(body["error"], "invalid_grant")
def test_invalid_no_app(self):
"""test invalid JWT"""
self.app.provider = None
self.app.save()
token = self.provider.encode(
{
"sub": "foo",
"exp": datetime.now() + timedelta(hours=2),
}
)
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}",
"client_id": self.provider.client_id,
"client_assertion_type": "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
"client_assertion": token,
},
)
self.assertEqual(response.status_code, 400)
body = loads(response.content.decode())
self.assertEqual(body["error"], "invalid_grant")
def test_invalid_access_denied(self):
"""test invalid JWT"""
group = Group.objects.create(name="foo")
PolicyBinding.objects.create(
group=group,
target=self.app,
order=0,
)
token = self.provider.encode(
{
"sub": "foo",
"exp": datetime.now() + timedelta(hours=2),
}
)
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}",
"client_id": self.provider.client_id,
"client_assertion_type": "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
"client_assertion": token,
},
)
self.assertEqual(response.status_code, 400)
body = loads(response.content.decode())
self.assertEqual(body["error"], "invalid_grant")
def test_successful(self):
"""test successful"""
user = create_test_user()
token = self.other_provider.encode(
{
"sub": "foo",
"exp": datetime.now() + timedelta(hours=2),
}
)
AccessToken.objects.create(
provider=self.other_provider,
token=token,
user=user,
auth_time=now(),
)
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}",
"client_id": self.provider.client_id,
"client_assertion_type": "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
"client_assertion": token,
},
)
self.assertEqual(response.status_code, 200)
body = loads(response.content.decode())
self.assertEqual(body["token_type"], TOKEN_TYPE)
_, 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"], user.name)
self.assertEqual(jwt["preferred_username"], user.username)

View File

@ -37,16 +37,9 @@ class TestTokenClientCredentialsJWTSource(OAuthTestCase):
def setUp(self) -> None: def setUp(self) -> None:
super().setUp() super().setUp()
self.factory = RequestFactory() self.factory = RequestFactory()
self.other_cert = create_test_cert()
# Provider used as a helper to sign JWTs with the same key as the OAuth source has
self.helper_provider = OAuth2Provider.objects.create(
name=generate_id(),
authorization_flow=create_test_flow(),
signing_key=self.other_cert,
)
self.cert = create_test_cert() self.cert = create_test_cert()
jwk = JWKSView().get_jwk_for_key(self.other_cert, "sig") jwk = JWKSView().get_jwk_for_key(self.cert, "sig")
self.source: OAuthSource = OAuthSource.objects.create( self.source: OAuthSource = OAuthSource.objects.create(
name=generate_id(), name=generate_id(),
slug=generate_id(), slug=generate_id(),
@ -69,7 +62,7 @@ class TestTokenClientCredentialsJWTSource(OAuthTestCase):
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://testserver")], redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://testserver")],
signing_key=self.cert, signing_key=self.cert,
) )
self.provider.jwt_federation_sources.add(self.source) self.provider.jwks_sources.add(self.source)
self.provider.property_mappings.set(ScopeMapping.objects.all()) self.provider.property_mappings.set(ScopeMapping.objects.all())
self.app = Application.objects.create(name="test", slug="test", provider=self.provider) self.app = Application.objects.create(name="test", slug="test", provider=self.provider)
@ -107,7 +100,7 @@ class TestTokenClientCredentialsJWTSource(OAuthTestCase):
def test_invalid_signature(self): def test_invalid_signature(self):
"""test invalid JWT""" """test invalid JWT"""
token = self.helper_provider.encode( token = self.provider.encode(
{ {
"sub": "foo", "sub": "foo",
"exp": datetime.now() + timedelta(hours=2), "exp": datetime.now() + timedelta(hours=2),
@ -129,7 +122,7 @@ class TestTokenClientCredentialsJWTSource(OAuthTestCase):
def test_invalid_expired(self): def test_invalid_expired(self):
"""test invalid JWT""" """test invalid JWT"""
token = self.helper_provider.encode( token = self.provider.encode(
{ {
"sub": "foo", "sub": "foo",
"exp": datetime.now() - timedelta(hours=2), "exp": datetime.now() - timedelta(hours=2),
@ -153,7 +146,7 @@ class TestTokenClientCredentialsJWTSource(OAuthTestCase):
"""test invalid JWT""" """test invalid JWT"""
self.app.provider = None self.app.provider = None
self.app.save() self.app.save()
token = self.helper_provider.encode( token = self.provider.encode(
{ {
"sub": "foo", "sub": "foo",
"exp": datetime.now() + timedelta(hours=2), "exp": datetime.now() + timedelta(hours=2),
@ -181,7 +174,7 @@ class TestTokenClientCredentialsJWTSource(OAuthTestCase):
target=self.app, target=self.app,
order=0, order=0,
) )
token = self.helper_provider.encode( token = self.provider.encode(
{ {
"sub": "foo", "sub": "foo",
"exp": datetime.now() + timedelta(hours=2), "exp": datetime.now() + timedelta(hours=2),
@ -203,7 +196,7 @@ class TestTokenClientCredentialsJWTSource(OAuthTestCase):
def test_successful(self): def test_successful(self):
"""test successful""" """test successful"""
token = self.helper_provider.encode( token = self.provider.encode(
{ {
"sub": "foo", "sub": "foo",
"exp": datetime.now() + timedelta(hours=2), "exp": datetime.now() + timedelta(hours=2),

View File

@ -45,7 +45,7 @@ class TestTokenPKCE(OAuthTestCase):
challenge = generate_id() challenge = generate_id()
header = b64encode(f"{provider.client_id}:{provider.client_secret}".encode()).decode() header = b64encode(f"{provider.client_id}:{provider.client_secret}".encode()).decode()
# Step 1, initiate params and get redirect to flow # Step 1, initiate params and get redirect to flow
response = self.client.get( self.client.get(
reverse("authentik_providers_oauth2:authorize"), reverse("authentik_providers_oauth2:authorize"),
data={ data={
"response_type": "code", "response_type": "code",
@ -56,10 +56,16 @@ class TestTokenPKCE(OAuthTestCase):
"code_challenge_method": "S256", "code_challenge_method": "S256",
}, },
) )
response = self.client.get(
reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}),
)
code: AuthorizationCode = AuthorizationCode.objects.filter(user=user).first() code: AuthorizationCode = AuthorizationCode.objects.filter(user=user).first()
self.assertEqual( self.assertJSONEqual(
response.url, response.content.decode(),
f"foo://localhost?code={code.code}&state={state}", {
"component": "xak-flow-redirect",
"to": f"foo://localhost?code={code.code}&state={state}",
},
) )
response = self.client.post( response = self.client.post(
reverse("authentik_providers_oauth2:token"), reverse("authentik_providers_oauth2:token"),
@ -101,7 +107,7 @@ class TestTokenPKCE(OAuthTestCase):
self.client.force_login(user) self.client.force_login(user)
header = b64encode(f"{provider.client_id}:{provider.client_secret}".encode()).decode() header = b64encode(f"{provider.client_id}:{provider.client_secret}".encode()).decode()
# Step 1, initiate params and get redirect to flow # Step 1, initiate params and get redirect to flow
response = self.client.get( self.client.get(
reverse("authentik_providers_oauth2:authorize"), reverse("authentik_providers_oauth2:authorize"),
data={ data={
"response_type": "code", "response_type": "code",
@ -112,10 +118,16 @@ class TestTokenPKCE(OAuthTestCase):
# "code_challenge_method": "S256", # "code_challenge_method": "S256",
}, },
) )
response = self.client.get(
reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}),
)
code: AuthorizationCode = AuthorizationCode.objects.filter(user=user).first() code: AuthorizationCode = AuthorizationCode.objects.filter(user=user).first()
self.assertEqual( self.assertJSONEqual(
response.url, response.content.decode(),
f"foo://localhost?code={code.code}&state={state}", {
"component": "xak-flow-redirect",
"to": f"foo://localhost?code={code.code}&state={state}",
},
) )
response = self.client.post( response = self.client.post(
reverse("authentik_providers_oauth2:token"), reverse("authentik_providers_oauth2:token"),
@ -162,7 +174,7 @@ class TestTokenPKCE(OAuthTestCase):
) )
header = b64encode(f"{provider.client_id}:{provider.client_secret}".encode()).decode() header = b64encode(f"{provider.client_id}:{provider.client_secret}".encode()).decode()
# Step 1, initiate params and get redirect to flow # Step 1, initiate params and get redirect to flow
response = self.client.get( self.client.get(
reverse("authentik_providers_oauth2:authorize"), reverse("authentik_providers_oauth2:authorize"),
data={ data={
"response_type": "code", "response_type": "code",
@ -173,10 +185,16 @@ class TestTokenPKCE(OAuthTestCase):
"code_challenge_method": "S256", "code_challenge_method": "S256",
}, },
) )
response = self.client.get(
reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}),
)
code: AuthorizationCode = AuthorizationCode.objects.filter(user=user).first() code: AuthorizationCode = AuthorizationCode.objects.filter(user=user).first()
self.assertEqual( self.assertJSONEqual(
response.url, response.content.decode(),
f"foo://localhost?code={code.code}&state={state}", {
"component": "xak-flow-redirect",
"to": f"foo://localhost?code={code.code}&state={state}",
},
) )
response = self.client.post( response = self.client.post(
reverse("authentik_providers_oauth2:token"), reverse("authentik_providers_oauth2:token"),
@ -207,7 +225,7 @@ class TestTokenPKCE(OAuthTestCase):
verifier = generate_id() verifier = generate_id()
header = b64encode(f"{provider.client_id}:{provider.client_secret}".encode()).decode() header = b64encode(f"{provider.client_id}:{provider.client_secret}".encode()).decode()
# Step 1, initiate params and get redirect to flow # Step 1, initiate params and get redirect to flow
response = self.client.get( self.client.get(
reverse("authentik_providers_oauth2:authorize"), reverse("authentik_providers_oauth2:authorize"),
data={ data={
"response_type": "code", "response_type": "code",
@ -217,10 +235,16 @@ class TestTokenPKCE(OAuthTestCase):
"code_challenge": verifier, "code_challenge": verifier,
}, },
) )
response = self.client.get(
reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}),
)
code: AuthorizationCode = AuthorizationCode.objects.filter(user=user).first() code: AuthorizationCode = AuthorizationCode.objects.filter(user=user).first()
self.assertEqual( self.assertJSONEqual(
response.url, response.content.decode(),
f"foo://localhost?code={code.code}&state={state}", {
"component": "xak-flow-redirect",
"to": f"foo://localhost?code={code.code}&state={state}",
},
) )
response = self.client.post( response = self.client.post(
reverse("authentik_providers_oauth2:token"), reverse("authentik_providers_oauth2:token"),

View File

@ -27,7 +27,9 @@ from authentik.flows.exceptions import FlowNonApplicableException
from authentik.flows.models import in_memory_stage from authentik.flows.models import in_memory_stage
from authentik.flows.planner import PLAN_CONTEXT_APPLICATION, PLAN_CONTEXT_SSO, FlowPlanner from authentik.flows.planner import PLAN_CONTEXT_APPLICATION, PLAN_CONTEXT_SSO, FlowPlanner
from authentik.flows.stage import StageView from authentik.flows.stage import StageView
from authentik.flows.views.executor import SESSION_KEY_PLAN
from authentik.lib.utils.time import timedelta_from_string from authentik.lib.utils.time import timedelta_from_string
from authentik.lib.utils.urls import redirect_with_qs
from authentik.lib.views import bad_request_message from authentik.lib.views import bad_request_message
from authentik.policies.types import PolicyRequest from authentik.policies.types import PolicyRequest
from authentik.policies.views import PolicyAccessView, RequestValidationError from authentik.policies.views import PolicyAccessView, RequestValidationError
@ -452,16 +454,11 @@ class AuthorizationFlowInitView(PolicyAccessView):
plan.append_stage(in_memory_stage(OAuthFulfillmentStage)) plan.append_stage(in_memory_stage(OAuthFulfillmentStage))
return plan.to_redirect( self.request.session[SESSION_KEY_PLAN] = plan
self.request, return redirect_with_qs(
self.provider.authorization_flow, "authentik_core:if-flow",
# We can only skip the flow executor and directly go to the final redirect URL if self.request.GET,
# we can submit the data to the RP via URL flow_slug=self.provider.authorization_flow.slug,
allowed_silent_types=(
[OAuthFulfillmentStage]
if self.params.response_mode in [ResponseMode.QUERY, ResponseMode.FRAGMENT]
else []
),
) )

View File

@ -16,6 +16,7 @@ from authentik.flows.models import in_memory_stage
from authentik.flows.planner import PLAN_CONTEXT_APPLICATION, PLAN_CONTEXT_SSO, FlowPlanner from authentik.flows.planner import PLAN_CONTEXT_APPLICATION, PLAN_CONTEXT_SSO, FlowPlanner
from authentik.flows.stage import ChallengeStageView from authentik.flows.stage import ChallengeStageView
from authentik.flows.views.executor import SESSION_KEY_PLAN from authentik.flows.views.executor import SESSION_KEY_PLAN
from authentik.lib.utils.urls import redirect_with_qs
from authentik.policies.views import PolicyAccessView from authentik.policies.views import PolicyAccessView
from authentik.providers.oauth2.models import DeviceToken from authentik.providers.oauth2.models import DeviceToken
from authentik.providers.oauth2.views.device_finish import ( from authentik.providers.oauth2.views.device_finish import (
@ -72,7 +73,12 @@ class CodeValidatorView(PolicyAccessView):
LOGGER.warning("Flow not applicable to user") LOGGER.warning("Flow not applicable to user")
return None return None
plan.insert_stage(in_memory_stage(OAuthDeviceCodeFinishStage)) plan.insert_stage(in_memory_stage(OAuthDeviceCodeFinishStage))
return plan.to_redirect(self.request, self.token.provider.authorization_flow) request.session[SESSION_KEY_PLAN] = plan
return redirect_with_qs(
"authentik_core:if-flow",
request.GET,
flow_slug=self.token.provider.authorization_flow.slug,
)
class DeviceEntryView(PolicyAccessView): class DeviceEntryView(PolicyAccessView):
@ -103,7 +109,11 @@ class DeviceEntryView(PolicyAccessView):
plan.append_stage(in_memory_stage(OAuthDeviceCodeStage)) plan.append_stage(in_memory_stage(OAuthDeviceCodeStage))
self.request.session[SESSION_KEY_PLAN] = plan self.request.session[SESSION_KEY_PLAN] = plan
return plan.to_redirect(self.request, device_flow) return redirect_with_qs(
"authentik_core:if-flow",
self.request.GET,
flow_slug=device_flow.slug,
)
class OAuthDeviceCodeChallenge(Challenge): class OAuthDeviceCodeChallenge(Challenge):
@ -127,7 +137,7 @@ class OAuthDeviceCodeChallengeResponse(ChallengeResponse):
class OAuthDeviceCodeStage(ChallengeStageView): class OAuthDeviceCodeStage(ChallengeStageView):
"""Flow challenge for users to enter device code""" """Flow challenge for users to enter device codes"""
response_class = OAuthDeviceCodeChallengeResponse response_class = OAuthDeviceCodeChallengeResponse

View File

@ -7,6 +7,8 @@ from authentik.core.models import Application
from authentik.flows.models import Flow, in_memory_stage from authentik.flows.models import Flow, in_memory_stage
from authentik.flows.planner import PLAN_CONTEXT_APPLICATION, FlowPlanner from authentik.flows.planner import PLAN_CONTEXT_APPLICATION, FlowPlanner
from authentik.flows.stage import SessionEndStage from authentik.flows.stage import SessionEndStage
from authentik.flows.views.executor import SESSION_KEY_PLAN
from authentik.lib.utils.urls import redirect_with_qs
from authentik.policies.views import PolicyAccessView from authentik.policies.views import PolicyAccessView
@ -35,4 +37,9 @@ class EndSessionView(PolicyAccessView):
}, },
) )
plan.insert_stage(in_memory_stage(SessionEndStage)) plan.insert_stage(in_memory_stage(SessionEndStage))
return plan.to_redirect(self.request, self.flow) request.session[SESSION_KEY_PLAN] = plan
return redirect_with_qs(
"authentik_core:if-flow",
self.request.GET,
flow_slug=self.flow.slug,
)

View File

@ -362,9 +362,23 @@ class TokenParams:
}, },
).from_http(request, user=user) ).from_http(request, user=user)
def __validate_jwt_from_source( def __post_init_client_credentials_jwt(self, request: HttpRequest):
self, assertion: str assertion_type = request.POST.get(CLIENT_ASSERTION_TYPE, "")
) -> tuple[dict, OAuthSource] | tuple[None, None]: if assertion_type != CLIENT_ASSERTION_TYPE_JWT:
LOGGER.warning("Invalid assertion type", assertion_type=assertion_type)
raise TokenError("invalid_grant")
client_secret = request.POST.get("client_secret", None)
assertion = request.POST.get(CLIENT_ASSERTION, client_secret)
if not assertion:
LOGGER.warning("Missing client assertion")
raise TokenError("invalid_grant")
token = None
source: OAuthSource | None = None
parsed_key: PyJWK | None = None
# Fully decode the JWT without verifying the signature, so we can get access to # Fully decode the JWT without verifying the signature, so we can get access to
# the header. # the header.
# Get the Key ID from the header, and use that to optimise our source query to only find # Get the Key ID from the header, and use that to optimise our source query to only find
@ -379,23 +393,19 @@ class TokenParams:
LOGGER.warning("failed to parse JWT for kid lookup", exc=exc) LOGGER.warning("failed to parse JWT for kid lookup", exc=exc)
raise TokenError("invalid_grant") from None raise TokenError("invalid_grant") from None
expected_kid = decode_unvalidated["header"]["kid"] expected_kid = decode_unvalidated["header"]["kid"]
fallback_alg = decode_unvalidated["header"]["alg"] for source in self.provider.jwks_sources.filter(
token = source = None
for source in self.provider.jwt_federation_sources.filter(
oidc_jwks__keys__contains=[{"kid": expected_kid}] oidc_jwks__keys__contains=[{"kid": expected_kid}]
): ):
LOGGER.debug("verifying JWT with source", source=source.slug) LOGGER.debug("verifying JWT with source", source=source.slug)
keys = source.oidc_jwks.get("keys", []) keys = source.oidc_jwks.get("keys", [])
for key in keys: for key in keys:
if key.get("kid") and key.get("kid") != expected_kid:
continue
LOGGER.debug("verifying JWT with key", source=source.slug, key=key.get("kid")) LOGGER.debug("verifying JWT with key", source=source.slug, key=key.get("kid"))
try: try:
parsed_key = PyJWK.from_dict(key).key parsed_key = PyJWK.from_dict(key)
token = decode( token = decode(
assertion, assertion,
parsed_key, parsed_key.key,
algorithms=[key.get("alg")] if "alg" in key else [fallback_alg], algorithms=[key.get("alg")],
options={ options={
"verify_aud": False, "verify_aud": False,
}, },
@ -404,61 +414,13 @@ class TokenParams:
# and not a public key # and not a public key
except (PyJWTError, ValueError, TypeError, AttributeError) as exc: except (PyJWTError, ValueError, TypeError, AttributeError) as exc:
LOGGER.warning("failed to verify JWT", exc=exc, source=source.slug) LOGGER.warning("failed to verify JWT", exc=exc, source=source.slug)
if token:
LOGGER.info("successfully verified JWT with source", source=source.slug)
return token, source
def __validate_jwt_from_provider(
self, assertion: str
) -> tuple[dict, OAuth2Provider] | tuple[None, None]:
token = provider = _key = None
federated_token = AccessToken.objects.filter(
token=assertion, provider__in=self.provider.jwt_federation_providers.all()
).first()
if federated_token:
_key, _alg = federated_token.provider.jwt_key
try:
token = decode(
assertion,
_key.public_key(),
algorithms=[_alg],
options={
"verify_aud": False,
},
)
provider = federated_token.provider
self.user = federated_token.user
except (PyJWTError, ValueError, TypeError, AttributeError) as exc:
LOGGER.warning(
"failed to verify JWT", exc=exc, provider=federated_token.provider.name
)
if token:
LOGGER.info("successfully verified JWT with provider", provider=provider.name)
return token, provider
def __post_init_client_credentials_jwt(self, request: HttpRequest):
assertion_type = request.POST.get(CLIENT_ASSERTION_TYPE, "")
if assertion_type != CLIENT_ASSERTION_TYPE_JWT:
LOGGER.warning("Invalid assertion type", assertion_type=assertion_type)
raise TokenError("invalid_grant")
client_secret = request.POST.get("client_secret", None)
assertion = request.POST.get(CLIENT_ASSERTION, client_secret)
if not assertion:
LOGGER.warning("Missing client assertion")
raise TokenError("invalid_grant")
source = provider = None
token, source = self.__validate_jwt_from_source(assertion)
if not token:
token, provider = self.__validate_jwt_from_provider(assertion)
if not token: if not token:
LOGGER.warning("No token could be verified") LOGGER.warning("No token could be verified")
raise TokenError("invalid_grant") raise TokenError("invalid_grant")
LOGGER.info("successfully verified JWT with source", source=source.slug)
if "exp" in token: if "exp" in token:
exp = datetime.fromtimestamp(token["exp"]) exp = datetime.fromtimestamp(token["exp"])
# Non-timezone aware check since we assume `exp` is in UTC # Non-timezone aware check since we assume `exp` is in UTC
@ -472,16 +434,15 @@ class TokenParams:
raise TokenError("invalid_grant") raise TokenError("invalid_grant")
self.__check_policy_access(app, request, oauth_jwt=token) self.__check_policy_access(app, request, oauth_jwt=token)
if not provider: self.__create_user_from_jwt(token, app, source)
self.__create_user_from_jwt(token, app, source)
method_args = { method_args = {
"jwt": token, "jwt": token,
} }
if source: if source:
method_args["source"] = source method_args["source"] = source
if provider: if parsed_key:
method_args["provider"] = provider method_args["jwk_id"] = parsed_key.key_id
Event.new( Event.new(
action=EventAction.LOGIN, action=EventAction.LOGIN,
**{ **{

View File

@ -94,8 +94,7 @@ class ProxyProviderSerializer(ProviderSerializer):
"intercept_header_auth", "intercept_header_auth",
"redirect_uris", "redirect_uris",
"cookie_domain", "cookie_domain",
"jwt_federation_sources", "jwks_sources",
"jwt_federation_providers",
"access_token_validity", "access_token_validity",
"refresh_token_validity", "refresh_token_validity",
"outpost_set", "outpost_set",

View File

@ -127,7 +127,6 @@ class Traefik3MiddlewareReconciler(KubernetesObjectReconciler[TraefikMiddleware]
authResponseHeaders=[ authResponseHeaders=[
"X-authentik-username", "X-authentik-username",
"X-authentik-groups", "X-authentik-groups",
"X-authentik-entitlements",
"X-authentik-email", "X-authentik-email",
"X-authentik-name", "X-authentik-name",
"X-authentik-uid", "X-authentik-uid",

View File

@ -147,7 +147,6 @@ class ProxyProvider(OutpostModel, OAuth2Provider):
"goauthentik.io/providers/oauth2/scope-openid", "goauthentik.io/providers/oauth2/scope-openid",
"goauthentik.io/providers/oauth2/scope-profile", "goauthentik.io/providers/oauth2/scope-profile",
"goauthentik.io/providers/oauth2/scope-email", "goauthentik.io/providers/oauth2/scope-email",
"goauthentik.io/providers/oauth2/scope-entitlements",
"goauthentik.io/providers/proxy/scope-proxy", "goauthentik.io/providers/proxy/scope-proxy",
] ]
) )

View File

@ -54,23 +54,9 @@ class SAMLProviderSerializer(ProviderSerializer):
if "request" not in self._context: if "request" not in self._context:
return "" return ""
request: HttpRequest = self._context["request"]._request request: HttpRequest = self._context["request"]._request
try: return request.build_absolute_uri(
return request.build_absolute_uri( reverse("authentik_api:samlprovider-metadata", kwargs={"pk": instance.pk}) + "?download"
reverse( )
"authentik_providers_saml:metadata-download",
kwargs={"application_slug": instance.application.slug},
)
)
except Provider.application.RelatedObjectDoesNotExist:
return request.build_absolute_uri(
reverse(
"authentik_api:samlprovider-metadata",
kwargs={
"pk": instance.pk,
},
)
+ "?download"
)
def get_url_sso_post(self, instance: SAMLProvider) -> str: def get_url_sso_post(self, instance: SAMLProvider) -> str:
"""Get SSO Post URL""" """Get SSO Post URL"""

View File

@ -13,6 +13,8 @@ from authentik.events.models import Event, EventAction
from authentik.flows.models import Flow, in_memory_stage from authentik.flows.models import Flow, in_memory_stage
from authentik.flows.planner import PLAN_CONTEXT_APPLICATION, FlowPlanner from authentik.flows.planner import PLAN_CONTEXT_APPLICATION, FlowPlanner
from authentik.flows.stage import SessionEndStage from authentik.flows.stage import SessionEndStage
from authentik.flows.views.executor import SESSION_KEY_PLAN
from authentik.lib.utils.urls import redirect_with_qs
from authentik.lib.views import bad_request_message from authentik.lib.views import bad_request_message
from authentik.policies.views import PolicyAccessView from authentik.policies.views import PolicyAccessView
from authentik.providers.saml.exceptions import CannotHandleAssertion from authentik.providers.saml.exceptions import CannotHandleAssertion
@ -62,7 +64,12 @@ class SAMLSLOView(PolicyAccessView):
}, },
) )
plan.insert_stage(in_memory_stage(SessionEndStage)) plan.insert_stage(in_memory_stage(SessionEndStage))
return plan.to_redirect(self.request, self.flow) request.session[SESSION_KEY_PLAN] = plan
return redirect_with_qs(
"authentik_core:if-flow",
self.request.GET,
flow_slug=self.flow.slug,
)
def post(self, request: HttpRequest, application_slug: str) -> HttpResponse: def post(self, request: HttpRequest, application_slug: str) -> HttpResponse:
"""GET and POST use the same handler, but we can't """GET and POST use the same handler, but we can't

View File

@ -13,11 +13,12 @@ from authentik.events.models import Event, EventAction
from authentik.flows.exceptions import FlowNonApplicableException from authentik.flows.exceptions import FlowNonApplicableException
from authentik.flows.models import in_memory_stage from authentik.flows.models import in_memory_stage
from authentik.flows.planner import PLAN_CONTEXT_APPLICATION, PLAN_CONTEXT_SSO, FlowPlanner from authentik.flows.planner import PLAN_CONTEXT_APPLICATION, PLAN_CONTEXT_SSO, FlowPlanner
from authentik.flows.views.executor import SESSION_KEY_POST from authentik.flows.views.executor import SESSION_KEY_PLAN, SESSION_KEY_POST
from authentik.lib.utils.urls import redirect_with_qs
from authentik.lib.views import bad_request_message from authentik.lib.views import bad_request_message
from authentik.policies.views import PolicyAccessView from authentik.policies.views import PolicyAccessView
from authentik.providers.saml.exceptions import CannotHandleAssertion from authentik.providers.saml.exceptions import CannotHandleAssertion
from authentik.providers.saml.models import SAMLBindings, SAMLProvider from authentik.providers.saml.models import SAMLProvider
from authentik.providers.saml.processors.authn_request_parser import AuthNRequestParser from authentik.providers.saml.processors.authn_request_parser import AuthNRequestParser
from authentik.providers.saml.views.flows import ( from authentik.providers.saml.views.flows import (
REQUEST_KEY_RELAY_STATE, REQUEST_KEY_RELAY_STATE,
@ -73,12 +74,11 @@ class SAMLSSOView(PolicyAccessView):
except FlowNonApplicableException: except FlowNonApplicableException:
raise Http404 from None raise Http404 from None
plan.append_stage(in_memory_stage(SAMLFlowFinalView)) plan.append_stage(in_memory_stage(SAMLFlowFinalView))
return plan.to_redirect( request.session[SESSION_KEY_PLAN] = plan
request, return redirect_with_qs(
self.provider.authorization_flow, "authentik_core:if-flow",
allowed_silent_types=( request.GET,
[SAMLFlowFinalView] if self.provider.sp_binding in [SAMLBindings.REDIRECT] else [] flow_slug=self.provider.authorization_flow.slug,
),
) )
def post(self, request: HttpRequest, application_slug: str) -> HttpResponse: def post(self, request: HttpRequest, application_slug: str) -> HttpResponse:

View File

@ -12,7 +12,7 @@ from sentry_sdk import set_tag
from xmlsec import enable_debug_trace from xmlsec import enable_debug_trace
from authentik import __version__ from authentik import __version__
from authentik.lib.config import CONFIG, django_db_config, redis_url from authentik.lib.config import CONFIG, redis_url
from authentik.lib.logging import get_logger_config, structlog_configure from authentik.lib.logging import get_logger_config, structlog_configure
from authentik.lib.sentry import sentry_init from authentik.lib.sentry import sentry_init
from authentik.lib.utils.reflection import get_env from authentik.lib.utils.reflection import get_env
@ -32,14 +32,13 @@ LOGIN_URL = "authentik_flows:default-authentication"
# Custom user model # Custom user model
AUTH_USER_MODEL = "authentik_core.User" AUTH_USER_MODEL = "authentik_core.User"
CSRF_COOKIE_PATH = LANGUAGE_COOKIE_PATH = SESSION_COOKIE_PATH = CONFIG.get("web.path", "/")
CSRF_COOKIE_NAME = "authentik_csrf" CSRF_COOKIE_NAME = "authentik_csrf"
CSRF_HEADER_NAME = "HTTP_X_AUTHENTIK_CSRF" CSRF_HEADER_NAME = "HTTP_X_AUTHENTIK_CSRF"
LANGUAGE_COOKIE_NAME = "authentik_language" LANGUAGE_COOKIE_NAME = "authentik_language"
SESSION_COOKIE_NAME = "authentik_session" SESSION_COOKIE_NAME = "authentik_session"
SESSION_COOKIE_DOMAIN = CONFIG.get("cookie_domain", None) SESSION_COOKIE_DOMAIN = CONFIG.get("cookie_domain", None)
APPEND_SLASH = False APPEND_SLASH = False
X_FRAME_OPTIONS = "SAMEORIGIN"
AUTHENTICATION_BACKENDS = [ AUTHENTICATION_BACKENDS = [
"django.contrib.auth.backends.ModelBackend", "django.contrib.auth.backends.ModelBackend",
@ -114,7 +113,6 @@ TENANT_APPS = [
"authentik.stages.invitation", "authentik.stages.invitation",
"authentik.stages.password", "authentik.stages.password",
"authentik.stages.prompt", "authentik.stages.prompt",
"authentik.stages.redirect",
"authentik.stages.user_delete", "authentik.stages.user_delete",
"authentik.stages.user_login", "authentik.stages.user_login",
"authentik.stages.user_logout", "authentik.stages.user_logout",
@ -298,7 +296,45 @@ CHANNEL_LAYERS = {
# https://docs.djangoproject.com/en/2.1/ref/settings/#databases # https://docs.djangoproject.com/en/2.1/ref/settings/#databases
ORIGINAL_BACKEND = "django_prometheus.db.backends.postgresql" ORIGINAL_BACKEND = "django_prometheus.db.backends.postgresql"
DATABASES = django_db_config() DATABASES = {
"default": {
"ENGINE": "authentik.root.db",
"HOST": CONFIG.get("postgresql.host"),
"NAME": CONFIG.get("postgresql.name"),
"USER": CONFIG.get("postgresql.user"),
"PASSWORD": CONFIG.get("postgresql.password"),
"PORT": CONFIG.get("postgresql.port"),
"SSLMODE": CONFIG.get("postgresql.sslmode"),
"SSLROOTCERT": CONFIG.get("postgresql.sslrootcert"),
"SSLCERT": CONFIG.get("postgresql.sslcert"),
"SSLKEY": CONFIG.get("postgresql.sslkey"),
"TEST": {
"NAME": CONFIG.get("postgresql.test.name"),
},
}
}
if CONFIG.get_bool("postgresql.use_pgpool", False):
DATABASES["default"]["DISABLE_SERVER_SIDE_CURSORS"] = True
if CONFIG.get_bool("postgresql.use_pgbouncer", False):
# https://docs.djangoproject.com/en/4.0/ref/databases/#transaction-pooling-server-side-cursors
DATABASES["default"]["DISABLE_SERVER_SIDE_CURSORS"] = True
# https://docs.djangoproject.com/en/4.0/ref/databases/#persistent-connections
DATABASES["default"]["CONN_MAX_AGE"] = None # persistent
for replica in CONFIG.get_keys("postgresql.read_replicas"):
_database = DATABASES["default"].copy()
for setting in DATABASES["default"].keys():
default = object()
if setting in ("TEST",):
continue
override = CONFIG.get(
f"postgresql.read_replicas.{replica}.{setting.lower()}", default=default
)
if override is not default:
_database[setting] = override
DATABASES[f"replica_{replica}"] = _database
DATABASE_ROUTERS = ( DATABASE_ROUTERS = (
"authentik.tenants.db.FailoverRouter", "authentik.tenants.db.FailoverRouter",
@ -389,7 +425,7 @@ if _ERROR_REPORTING:
# https://docs.djangoproject.com/en/2.1/howto/static-files/ # https://docs.djangoproject.com/en/2.1/howto/static-files/
STATICFILES_DIRS = [BASE_DIR / Path("web")] STATICFILES_DIRS = [BASE_DIR / Path("web")]
STATIC_URL = CONFIG.get("web.path", "/") + "static/" STATIC_URL = "/static/"
STORAGES = { STORAGES = {
"staticfiles": { "staticfiles": {

View File

@ -4,7 +4,6 @@ from django.urls import include, path
from structlog.stdlib import get_logger from structlog.stdlib import get_logger
from authentik.core.views import error from authentik.core.views import error
from authentik.lib.config import CONFIG
from authentik.lib.utils.reflection import get_apps from authentik.lib.utils.reflection import get_apps
from authentik.root.monitoring import LiveView, MetricsView, ReadyView from authentik.root.monitoring import LiveView, MetricsView, ReadyView
@ -15,7 +14,7 @@ handler403 = error.ForbiddenView.as_view()
handler404 = error.NotFoundView.as_view() handler404 = error.NotFoundView.as_view()
handler500 = error.ServerErrorView.as_view() handler500 = error.ServerErrorView.as_view()
_urlpatterns = [] urlpatterns = []
for _authentik_app in get_apps(): for _authentik_app in get_apps():
mountpoints = None mountpoints = None
@ -36,7 +35,7 @@ for _authentik_app in get_apps():
namespace=namespace, namespace=namespace,
), ),
) )
_urlpatterns.append(_path) urlpatterns.append(_path)
LOGGER.debug( LOGGER.debug(
"Mounted URLs", "Mounted URLs",
app_name=_authentik_app.name, app_name=_authentik_app.name,
@ -44,10 +43,8 @@ for _authentik_app in get_apps():
namespace=namespace, namespace=namespace,
) )
_urlpatterns += [ urlpatterns += [
path("-/metrics/", MetricsView.as_view(), name="metrics"), path("-/metrics/", MetricsView.as_view(), name="metrics"),
path("-/health/live/", LiveView.as_view(), name="health-live"), path("-/health/live/", LiveView.as_view(), name="health-live"),
path("-/health/ready/", ReadyView.as_view(), name="health-ready"), path("-/health/ready/", ReadyView.as_view(), name="health-ready"),
] ]
urlpatterns = [path(CONFIG.get("web.path", "/")[1:], include(_urlpatterns))]

View File

@ -2,16 +2,13 @@
from importlib import import_module from importlib import import_module
from channels.routing import URLRouter
from django.urls import path
from structlog.stdlib import get_logger from structlog.stdlib import get_logger
from authentik.lib.config import CONFIG
from authentik.lib.utils.reflection import get_apps from authentik.lib.utils.reflection import get_apps
LOGGER = get_logger() LOGGER = get_logger()
_websocket_urlpatterns = [] websocket_urlpatterns = []
for _authentik_app in get_apps(): for _authentik_app in get_apps():
try: try:
api_urls = import_module(f"{_authentik_app.name}.urls") api_urls = import_module(f"{_authentik_app.name}.urls")
@ -20,15 +17,8 @@ for _authentik_app in get_apps():
if not hasattr(api_urls, "websocket_urlpatterns"): if not hasattr(api_urls, "websocket_urlpatterns"):
continue continue
urls: list = api_urls.websocket_urlpatterns urls: list = api_urls.websocket_urlpatterns
_websocket_urlpatterns.extend(urls) websocket_urlpatterns.extend(urls)
LOGGER.debug( LOGGER.debug(
"Mounted Websocket URLs", "Mounted Websocket URLs",
app_name=_authentik_app.name, app_name=_authentik_app.name,
) )
websocket_urlpatterns = [
path(
CONFIG.get("web.path", "/")[1:],
URLRouter(_websocket_urlpatterns),
),
]

View File

@ -32,7 +32,6 @@ class KerberosSourceSerializer(SourceSerializer):
"group_matching_mode", "group_matching_mode",
"realm", "realm",
"krb5_conf", "krb5_conf",
"kadmin_type",
"sync_users", "sync_users",
"sync_users_password", "sync_users_password",
"sync_principal", "sync_principal",
@ -70,7 +69,6 @@ class KerberosSourceViewSet(UsedByMixin, ModelViewSet):
"slug", "slug",
"enabled", "enabled",
"realm", "realm",
"kadmin_type",
"sync_users", "sync_users",
"sync_users_password", "sync_users_password",
"sync_principal", "sync_principal",

View File

@ -1,22 +0,0 @@
# Generated by Django 5.0.10 on 2024-12-06 19:24
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("authentik_sources_kerberos", "0001_initial"),
]
operations = [
migrations.AddField(
model_name="kerberossource",
name="kadmin_type",
field=models.TextField(
choices=[("MIT", "Mit"), ("Heimdal", "Heimdal"), ("other", "Other")],
default="other",
help_text="KAdmin server type",
),
),
]

View File

@ -13,7 +13,7 @@ from django.http import HttpRequest
from django.shortcuts import reverse from django.shortcuts import reverse
from django.templatetags.static import static from django.templatetags.static import static
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from kadmin import KAdmin, KAdminApiVersion from kadmin import KAdmin
from kadmin.exceptions import PyKAdminException from kadmin.exceptions import PyKAdminException
from rest_framework.serializers import Serializer from rest_framework.serializers import Serializer
from structlog.stdlib import get_logger from structlog.stdlib import get_logger
@ -36,12 +36,6 @@ LOGGER = get_logger()
_kadmin_connections: dict[str, Any] = {} _kadmin_connections: dict[str, Any] = {}
class KAdminType(models.TextChoices):
MIT = "MIT"
HEIMDAL = "Heimdal"
OTHER = "other"
class KerberosSource(Source): class KerberosSource(Source):
"""Federate Kerberos realm with authentik""" """Federate Kerberos realm with authentik"""
@ -50,9 +44,6 @@ class KerberosSource(Source):
blank=True, blank=True,
help_text=_("Custom krb5.conf to use. Uses the system one by default"), help_text=_("Custom krb5.conf to use. Uses the system one by default"),
) )
kadmin_type = models.TextField(
choices=KAdminType.choices, default=KAdminType.OTHER, help_text=_("KAdmin server type")
)
sync_users = models.BooleanField( sync_users = models.BooleanField(
default=False, help_text=_("Sync users from Kerberos into authentik"), db_index=True default=False, help_text=_("Sync users from Kerberos into authentik"), db_index=True
@ -208,14 +199,6 @@ class KerberosSource(Source):
return str(conf_path) return str(conf_path)
def _kadmin_init(self) -> KAdmin | None: def _kadmin_init(self) -> KAdmin | None:
api_version = None
match self.kadmin_type:
case KAdminType.MIT:
api_version = KAdminApiVersion.Version4
case KAdminType.HEIMDAL:
api_version = KAdminApiVersion.Version2
case KAdminType.OTHER:
api_version = KAdminApiVersion.Version2
# kadmin doesn't use a ccache for its connection # kadmin doesn't use a ccache for its connection
# as such, we don't need to create a separate ccache for each source # as such, we don't need to create a separate ccache for each source
if not self.sync_principal: if not self.sync_principal:
@ -224,7 +207,6 @@ class KerberosSource(Source):
return KAdmin.with_password( return KAdmin.with_password(
self.sync_principal, self.sync_principal,
self.sync_password, self.sync_password,
api_version=api_version,
) )
if self.sync_keytab: if self.sync_keytab:
keytab = self.sync_keytab keytab = self.sync_keytab
@ -236,13 +218,11 @@ class KerberosSource(Source):
return KAdmin.with_keytab( return KAdmin.with_keytab(
self.sync_principal, self.sync_principal,
keytab, keytab,
api_version=api_version,
) )
if self.sync_ccache: if self.sync_ccache:
return KAdmin.with_ccache( return KAdmin.with_ccache(
self.sync_principal, self.sync_principal,
self.sync_ccache, self.sync_ccache,
api_version=api_version,
) )
return None return None

View File

@ -45,10 +45,8 @@ def kerberos_sync_password(sender, user: User, password: str, **_):
continue continue
with Krb5ConfContext(source): with Krb5ConfContext(source):
try: try:
kadm = source.connection() source.connection().getprinc(user_source_connection.identifier).change_password(
kadm.get_principal(user_source_connection.identifier).change_password( password
kadm,
password,
) )
except PyKAdminException as exc: except PyKAdminException as exc:
LOGGER.warning("failed to set Kerberos password", exc=exc, source=source) LOGGER.warning("failed to set Kerberos password", exc=exc, source=source)

View File

@ -43,10 +43,8 @@ class KerberosSync:
self._messages = [] self._messages = []
self._logger = get_logger().bind(source=self._source, syncer=self.__class__.__name__) self._logger = get_logger().bind(source=self._source, syncer=self.__class__.__name__)
self.mapper = SourceMapper(self._source) self.mapper = SourceMapper(self._source)
self.user_manager = self.mapper.get_manager(User, ["principal", "principal_obj"]) self.user_manager = self.mapper.get_manager(User, ["principal"])
self.group_manager = self.mapper.get_manager( self.group_manager = self.mapper.get_manager(Group, ["group_id", "principal"])
Group, ["group_id", "principal", "principal_obj"]
)
self.matcher = SourceMatcher( self.matcher = SourceMatcher(
self._source, UserKerberosSourceConnection, GroupKerberosSourceConnection self._source, UserKerberosSourceConnection, GroupKerberosSourceConnection
) )
@ -69,16 +67,12 @@ class KerberosSync:
def _handle_principal(self, principal: str) -> bool: def _handle_principal(self, principal: str) -> bool:
try: try:
# TODO: handle permission error
principal_obj = self._connection.get_principal(principal)
defaults = self.mapper.build_object_properties( defaults = self.mapper.build_object_properties(
object_type=User, object_type=User,
manager=self.user_manager, manager=self.user_manager,
user=None, user=None,
request=None, request=None,
principal=principal, principal=principal,
principal_obj=principal_obj,
) )
self._logger.debug("Writing user with attributes", **defaults) self._logger.debug("Writing user with attributes", **defaults)
if "username" not in defaults: if "username" not in defaults:
@ -97,7 +91,6 @@ class KerberosSync:
request=None, request=None,
group_id=group_id, group_id=group_id,
principal=principal, principal=principal,
principal_obj=principal_obj,
) )
for group_id in defaults.pop("groups", []) for group_id in defaults.pop("groups", [])
} }

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