Compare commits

...

136 Commits

Author SHA1 Message Date
b19271bb03 web/admin: WIP Fix issue on Safari where sidebar collapse.
- TODO: Add width check.
2025-04-23 07:07:54 +02:00
88f112db87 web/admin: Fix reflow issues. 2025-04-22 23:09:48 +02:00
a1de44cd07 web: Fix issue where references to Lit SSR break page styles. 2025-04-22 20:13:34 +02:00
8869df4b1d web/admin: Fix layout centering. Adjust theming. 2025-04-22 20:13:01 +02:00
fffcb00f39 website/docs: updates style guide code block section (#14088)
* Removed multiline code block section. Added docusaurus style codeblock section. Fixed some capitalisation.

* Update website/docs/developer-docs/docs/style-guide.mdx

Co-authored-by: Dominic R <dominic@sdko.org>
Signed-off-by: Dewi Roberts <dewi@goauthentik.io>

* Added highlighting info and fixed formatting.

* Typo and prettier.

---------

Signed-off-by: Dewi Roberts <dewi@goauthentik.io>
Co-authored-by: Dominic R <dominic@sdko.org>
2025-04-22 12:10:06 -05:00
77ee868573 website: components: delete multilinecodeblock src (#14094)
* Delete website/src/components/MultilineCodeblock/index.tsx

Signed-off-by: Dominic R <dominic@sdko.org>

* Delete website/src/components/MultilineCodeblock/styles.css

Signed-off-by: Dominic R <dominic@sdko.org>

---------

Signed-off-by: Dominic R <dominic@sdko.org>
2025-04-22 17:41:41 +01:00
6aaec08496 Revert "policies: buffered policy access view for concurrent authorization attempts when unauthenticated (#13629)" (#14180) 2025-04-22 15:45:45 +00:00
cc15584650 core: bump uvicorn from 0.34.1 to v0.34.2 (#14175) 2025-04-22 17:14:01 +02:00
e55e446b89 website/integrations: add xcreds (#14163)
* Created guide and modified sidebar

* make

* Typo

* Clarified wording

* Added words

* Fixed login to vs log into

* Update website/integrations/services/xcreds/index.mdx

Signed-off-by: Tana M Berry <tanamarieberry@yahoo.com>

* Update website/integrations/services/xcreds/index.mdx

Signed-off-by: Tana M Berry <tanamarieberry@yahoo.com>

* Update website/integrations/services/xcreds/index.mdx

Signed-off-by: Tana M Berry <tanamarieberry@yahoo.com>

* Update website/integrations/services/xcreds/index.mdx

Signed-off-by: Tana M Berry <tanamarieberry@yahoo.com>

* Update website/integrations/services/xcreds/index.mdx

Signed-off-by: Tana M Berry <tanamarieberry@yahoo.com>

* Update website/integrations/services/xcreds/index.mdx

Signed-off-by: Tana M Berry <tanamarieberry@yahoo.com>

* Update website/integrations/services/xcreds/index.mdx

Signed-off-by: Tana M Berry <tanamarieberry@yahoo.com>

* Update website/integrations/services/xcreds/index.mdx

Signed-off-by: Tana M Berry <tanamarieberry@yahoo.com>

* Update website/integrations/services/xcreds/index.mdx

Signed-off-by: Tana M Berry <tanamarieberry@yahoo.com>

* Update website/integrations/services/xcreds/index.mdx

Signed-off-by: Tana M Berry <tanamarieberry@yahoo.com>

* Update website/integrations/services/xcreds/index.mdx

Signed-off-by: Tana M Berry <tanamarieberry@yahoo.com>

* Update website/integrations/services/xcreds/index.mdx

Signed-off-by: Tana M Berry <tanamarieberry@yahoo.com>

* Update website/integrations/services/xcreds/index.mdx

Signed-off-by: Tana M Berry <tanamarieberry@yahoo.com>

* Update website/integrations/services/xcreds/index.mdx

Signed-off-by: Tana M Berry <tanamarieberry@yahoo.com>

* Update website/integrations/services/xcreds/index.mdx

Signed-off-by: Tana M Berry <tanamarieberry@yahoo.com>

* Update website/integrations/services/xcreds/index.mdx

Signed-off-by: Tana M Berry <tanamarieberry@yahoo.com>

* Update website/integrations/services/xcreds/index.mdx

Signed-off-by: Tana M Berry <tanamarieberry@yahoo.com>

* Update website/integrations/services/xcreds/index.mdx

Signed-off-by: Tana M Berry <tanamarieberry@yahoo.com>

---------

Signed-off-by: Tana M Berry <tanamarieberry@yahoo.com>
Co-authored-by: Tana M Berry <tanamarieberry@yahoo.com>
2025-04-22 14:34:56 +00:00
76088e48b5 core, web: update translations (#14179)
Co-authored-by: rissson <18313093+rissson@users.noreply.github.com>
2025-04-22 13:56:32 +00:00
4165a0a6b2 web: update default flow background (#14115)
Co-authored-by: authentik-automation[bot] <135050075+authentik-automation[bot]@users.noreply.github.com>
2025-04-22 15:02:10 +02:00
647fefe5ce web: bump API Client version (#14176)
Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: authentik-automation[bot] <135050075+authentik-automation[bot]@users.noreply.github.com>
2025-04-22 15:01:42 +02:00
723dccdae3 enterprise/policies: Add Password Uniqueness History Policy (#13453)
Co-authored-by: David Gunter <david@davidgunter.ca>
Co-authored-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
2025-04-22 14:39:07 +02:00
c82f747e5e web/xliff: fix duplicated translations (#14164) 2025-04-22 12:13:34 +00:00
43406e2464 website/docs: fix postgres pool recommended settings (#14149) 2025-04-22 12:01:36 +00:00
a0ff0bef85 core: bump astral-sh/uv from 0.6.14 to 0.6.16 (#14161)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-04-22 13:57:29 +02:00
bedf548a5f web: fix scrollbar styling (#12600)
* Fix `.pf-c-card__body` scrollbars

* Fallback scrollbar styling for browsers that don't support `::-webkit-scrollbar`

---------

Co-authored-by: Marcelo Elizeche Landó <marcelo@goauthentik.io>
2025-04-22 04:47:02 +02:00
976e81c1dd website: integrations: gravity: fix issuer URL (#14155)
Signed-off-by: Dominic R <dominic@sdko.org>
2025-04-21 20:53:25 +02:00
ad733033d7 web: Packagify live reload plugin. (#14134)
* web: Packagify live reload plugin.

* web: Use shared formatter.

* web: Format.

* web: Use project mode typecheck.

* web: Fix type errors.
2025-04-21 19:07:45 +02:00
ba686f6a93 web: bump API Client version (#14062)
Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: authentik-automation[bot] <135050075+authentik-automation[bot]@users.noreply.github.com>
2025-04-18 17:17:30 +02:00
dc50be1e13 translate: Updates for file locale/en/LC_MESSAGES/django.po in zh-Hans (#14146)
Translate django.po in zh-Hans

100% translated source file: 'django.po'
on 'zh-Hans'.

Co-authored-by: transifex-integration[bot] <43880903+transifex-integration[bot]@users.noreply.github.com>
2025-04-18 17:17:03 +02:00
205686d252 translate: Updates for file web/xliff/en.xlf in zh_CN (#14145)
Translate web/xliff/en.xlf in zh_CN

100% translated source file: 'web/xliff/en.xlf'
on 'zh_CN'.

Co-authored-by: transifex-integration[bot] <43880903+transifex-integration[bot]@users.noreply.github.com>
2025-04-18 17:16:48 +02:00
6d589013e6 core: bump goauthentik.io/api/v3 from 3.2025024.7 to 3.2025024.8 (#14143)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-04-18 17:16:34 +02:00
2d6433ca9a translate: Updates for file locale/en/LC_MESSAGES/django.po in zh_CN (#14144)
Translate locale/en/LC_MESSAGES/django.po in zh_CN

100% translated source file: 'locale/en/LC_MESSAGES/django.po'
on 'zh_CN'.

Co-authored-by: transifex-integration[bot] <43880903+transifex-integration[bot]@users.noreply.github.com>
2025-04-18 17:16:23 +02:00
b5f07acb26 translate: Updates for file web/xliff/en.xlf in zh-Hans (#14139)
* Translate web/xliff/en.xlf in zh-Hans

100% translated source file: 'web/xliff/en.xlf'
on 'zh-Hans'.

* Removing web/xliff/en.xlf in zh-Hans

99% of minimum 100% translated source file: 'web/xliff/en.xlf'
on 'zh-Hans'.

* Translate web/xliff/en.xlf in zh-Hans

100% translated source file: 'web/xliff/en.xlf'
on 'zh-Hans'.

---------

Co-authored-by: transifex-integration[bot] <43880903+transifex-integration[bot]@users.noreply.github.com>
2025-04-18 17:16:10 +02:00
ea8702077c core, web: update translations (#14142)
Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: rissson <18313093+rissson@users.noreply.github.com>
2025-04-18 11:18:32 +02:00
6593357115 core: bump yarl from 1.19.0 to v1.20.0 (#14128) 2025-04-17 13:44:53 -04:00
6daed865c1 core: bump kombu from 5.5.2 to v5.5.3 (#14127) 2025-04-17 13:44:35 -04:00
c48a21707a core: bump boto3 from 1.37.34 to v1.37.35 (#14126) 2025-04-17 13:44:06 -04:00
e857770c0a core: bump automat from 24.8.1 to v25.4.16 (#14125) 2025-04-17 13:43:47 -04:00
add74c8799 website: bump http-proxy-middleware from 2.0.7 to 2.0.9 in /website (#14111)
Bumps [http-proxy-middleware](https://github.com/chimurai/http-proxy-middleware) from 2.0.7 to 2.0.9.
- [Release notes](https://github.com/chimurai/http-proxy-middleware/releases)
- [Changelog](https://github.com/chimurai/http-proxy-middleware/blob/v2.0.9/CHANGELOG.md)
- [Commits](https://github.com/chimurai/http-proxy-middleware/compare/v2.0.7...v2.0.9)

---
updated-dependencies:
- dependency-name: http-proxy-middleware
  dependency-version: 2.0.9
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-04-17 19:19:25 +02:00
12d854035d website: bump estree-util-value-to-estree from 3.1.1 to 3.3.3 in /website (#13808)
website: bump estree-util-value-to-estree in /website

Bumps [estree-util-value-to-estree](https://github.com/remcohaszing/estree-util-value-to-estree) from 3.1.1 to 3.3.3.
- [Release notes](https://github.com/remcohaszing/estree-util-value-to-estree/releases)
- [Commits](https://github.com/remcohaszing/estree-util-value-to-estree/compare/v3.1.1...v3.3.3)

---
updated-dependencies:
- dependency-name: estree-util-value-to-estree
  dependency-version: 3.3.3
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-04-17 19:02:02 +02:00
57dd4ae91d website: bump wireit from 0.14.11 to 0.14.12 in /website (#14003)
Bumps [wireit](https://github.com/google/wireit) from 0.14.11 to 0.14.12.
- [Changelog](https://github.com/google/wireit/blob/main/CHANGELOG.md)
- [Commits](https://github.com/google/wireit/compare/v0.14.11...v0.14.12)

---
updated-dependencies:
- dependency-name: wireit
  dependency-version: 0.14.12
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-04-17 19:01:46 +02:00
37fbc98177 website: bump the build group in /website with 9 updates (#13748)
Bumps the build group in /website with 9 updates:

| Package | From | To |
| --- | --- | --- |
| [@rspack/binding-darwin-arm64](https://github.com/web-infra-dev/rspack/tree/HEAD/packages/rspack) | `1.3.0` | `1.3.1` |
| [@rspack/binding-linux-arm64-gnu](https://github.com/web-infra-dev/rspack/tree/HEAD/packages/rspack) | `1.3.0` | `1.3.1` |
| [@rspack/binding-linux-x64-gnu](https://github.com/web-infra-dev/rspack/tree/HEAD/packages/rspack) | `1.3.0` | `1.3.1` |
| [@swc/core-darwin-arm64](https://github.com/swc-project/swc) | `1.11.13` | `1.11.16` |
| [@swc/core-linux-arm64-gnu](https://github.com/swc-project/swc) | `1.11.13` | `1.11.16` |
| [@swc/core-linux-x64-gnu](https://github.com/swc-project/swc) | `1.11.13` | `1.11.16` |
| [@swc/html-darwin-arm64](https://github.com/swc-project/swc) | `1.11.13` | `1.11.16` |
| [@swc/html-linux-arm64-gnu](https://github.com/swc-project/swc) | `1.11.13` | `1.11.16` |
| [@swc/html-linux-x64-gnu](https://github.com/swc-project/swc) | `1.11.13` | `1.11.16` |


Updates `@rspack/binding-darwin-arm64` from 1.3.0 to 1.3.1
- [Release notes](https://github.com/web-infra-dev/rspack/releases)
- [Commits](https://github.com/web-infra-dev/rspack/commits/v1.3.1/packages/rspack)

Updates `@rspack/binding-linux-arm64-gnu` from 1.3.0 to 1.3.1
- [Release notes](https://github.com/web-infra-dev/rspack/releases)
- [Commits](https://github.com/web-infra-dev/rspack/commits/v1.3.1/packages/rspack)

Updates `@rspack/binding-linux-x64-gnu` from 1.3.0 to 1.3.1
- [Release notes](https://github.com/web-infra-dev/rspack/releases)
- [Commits](https://github.com/web-infra-dev/rspack/commits/v1.3.1/packages/rspack)

Updates `@swc/core-darwin-arm64` from 1.11.13 to 1.11.16
- [Release notes](https://github.com/swc-project/swc/releases)
- [Changelog](https://github.com/swc-project/swc/blob/main/CHANGELOG.md)
- [Commits](https://github.com/swc-project/swc/compare/v1.11.13...v1.11.16)

Updates `@swc/core-linux-arm64-gnu` from 1.11.13 to 1.11.16
- [Release notes](https://github.com/swc-project/swc/releases)
- [Changelog](https://github.com/swc-project/swc/blob/main/CHANGELOG.md)
- [Commits](https://github.com/swc-project/swc/compare/v1.11.13...v1.11.16)

Updates `@swc/core-linux-x64-gnu` from 1.11.13 to 1.11.16
- [Release notes](https://github.com/swc-project/swc/releases)
- [Changelog](https://github.com/swc-project/swc/blob/main/CHANGELOG.md)
- [Commits](https://github.com/swc-project/swc/compare/v1.11.13...v1.11.16)

Updates `@swc/html-darwin-arm64` from 1.11.13 to 1.11.16
- [Release notes](https://github.com/swc-project/swc/releases)
- [Changelog](https://github.com/swc-project/swc/blob/main/CHANGELOG.md)
- [Commits](https://github.com/swc-project/swc/compare/v1.11.13...v1.11.16)

Updates `@swc/html-linux-arm64-gnu` from 1.11.13 to 1.11.16
- [Release notes](https://github.com/swc-project/swc/releases)
- [Changelog](https://github.com/swc-project/swc/blob/main/CHANGELOG.md)
- [Commits](https://github.com/swc-project/swc/compare/v1.11.13...v1.11.16)

Updates `@swc/html-linux-x64-gnu` from 1.11.13 to 1.11.16
- [Release notes](https://github.com/swc-project/swc/releases)
- [Changelog](https://github.com/swc-project/swc/blob/main/CHANGELOG.md)
- [Commits](https://github.com/swc-project/swc/compare/v1.11.13...v1.11.16)

---
updated-dependencies:
- dependency-name: "@rspack/binding-darwin-arm64"
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: build
- dependency-name: "@rspack/binding-linux-arm64-gnu"
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: build
- dependency-name: "@rspack/binding-linux-x64-gnu"
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: build
- dependency-name: "@swc/core-darwin-arm64"
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: build
- dependency-name: "@swc/core-linux-arm64-gnu"
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: build
- dependency-name: "@swc/core-linux-x64-gnu"
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: build
- dependency-name: "@swc/html-darwin-arm64"
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: build
- dependency-name: "@swc/html-linux-arm64-gnu"
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: build
- dependency-name: "@swc/html-linux-x64-gnu"
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: build
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-04-17 19:00:44 +02:00
14f216eb40 core: bump github.com/go-ldap/ldap/v3 from 3.4.10 to 3.4.11 (#14068)
Bumps [github.com/go-ldap/ldap/v3](https://github.com/go-ldap/ldap) from 3.4.10 to 3.4.11.
- [Release notes](https://github.com/go-ldap/ldap/releases)
- [Commits](https://github.com/go-ldap/ldap/compare/v3.4.10...v3.4.11)

---
updated-dependencies:
- dependency-name: github.com/go-ldap/ldap/v3
  dependency-version: 3.4.11
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-04-17 19:00:21 +02:00
1209dd022e translate: Updates for file web/xliff/en.xlf in fr (#14124)
Translate web/xliff/en.xlf in fr

100% translated source file: 'web/xliff/en.xlf'
on 'fr'.

Co-authored-by: transifex-integration[bot] <43880903+transifex-integration[bot]@users.noreply.github.com>
2025-04-17 10:58:55 +00:00
c96f13ac66 translate: Updates for file locale/en/LC_MESSAGES/django.po in fr (#14123)
* Translate locale/en/LC_MESSAGES/django.po in fr

100% translated source file: 'locale/en/LC_MESSAGES/django.po'
on 'fr'.

* Translate locale/en/LC_MESSAGES/django.po in fr

100% translated source file: 'locale/en/LC_MESSAGES/django.po'
on 'fr'.

---------

Co-authored-by: transifex-integration[bot] <43880903+transifex-integration[bot]@users.noreply.github.com>
2025-04-17 10:57:24 +00:00
5e6874cc1f web: add remember me feature to IdentificationStage (#10397)
Co-authored-by: Teffen Ellis <592134+GirlBossRush@users.noreply.github.com>
Co-authored-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
2025-04-17 10:37:49 +00:00
fb5053ec83 core: bump goauthentik.io/api/v3 from 3.2025024.6 to 3.2025024.7 (#14121)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-04-17 12:27:24 +02:00
6f7dc2c543 lifecycle/aws: bump aws-cdk from 2.1007.0 to 2.1010.0 in /lifecycle/aws (#14122)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-04-17 12:27:17 +02:00
542b69b224 core, web: update translations (#14117)
Co-authored-by: rissson <18313093+rissson@users.noreply.github.com>
2025-04-17 12:24:29 +02:00
c15c0cbe86 website: integrations: apache guacamole: Fix deprecated start-of-doc … (#14114)
website: integrations: apache guacamole: Fix deprecated start-of-doc values
2025-04-16 15:45:44 -05:00
c6fe0c1d85 website integrations: actual budget: remove old header and support_level (#14112) 2025-04-16 15:45:20 -05:00
07f0666a6f website/integrations: general cleanup and updates (#12716)
* squash commits for future merge conflict resolution, if any

* adventurelog cleanup + lint

* lint (again)

* Update website/integrations/services/adventurelog/index.mdx

Co-authored-by: Dewi Roberts <dewi@goauthentik.io>
Signed-off-by: Dominic R <dominic@sdko.org>

* Update website/integrations/services/actual-budget/index.mdx

Co-authored-by: Dewi Roberts <dewi@goauthentik.io>
Signed-off-by: Dominic R <dominic@sdko.org>

* Update website/integrations/services/apache-guacamole/index.mdx

Co-authored-by: Dewi Roberts <dewi@goauthentik.io>
Signed-off-by: Dominic R <dominic@sdko.org>

* Update website/integrations/services/gatus/index.mdx

Co-authored-by: Dewi Roberts <dewi@goauthentik.io>
Signed-off-by: Dominic R <dominic@sdko.org>

* Update website/integrations/services/bookstack/index.mdx

Co-authored-by: Dewi Roberts <dewi@goauthentik.io>
Signed-off-by: Dominic R <dominic@sdko.org>

* Update website/integrations/services/freshrss/index.mdx

Co-authored-by: Dewi Roberts <dewi@goauthentik.io>
Signed-off-by: Dominic R <dominic@sdko.org>

* Update website/integrations/services/budibase/index.md

Co-authored-by: Dewi Roberts <dewi@goauthentik.io>
Signed-off-by: Dominic R <dominic@sdko.org>

* Update website/integrations/services/cloudflare-access/index.md

Co-authored-by: Dewi Roberts <dewi@goauthentik.io>
Signed-off-by: Dominic R <dominic@sdko.org>

* Update website/integrations/services/dokuwiki/index.md

Co-authored-by: Dewi Roberts <dewi@goauthentik.io>
Signed-off-by: Dominic R <dominic@sdko.org>

* Update website/integrations/services/frappe/index.md

Co-authored-by: Dewi Roberts <dewi@goauthentik.io>
Signed-off-by: Dominic R <dominic@sdko.org>

* Update website/integrations/services/espocrm/index.md

Co-authored-by: Dewi Roberts <dewi@goauthentik.io>
Signed-off-by: Dominic R <dominic@sdko.org>

* Update website/integrations/services/fortimanager/index.md

Co-authored-by: Dewi Roberts <dewi@goauthentik.io>
Signed-off-by: Dominic R <dominic@sdko.org>

* Update website/integrations/services/fortigate-admin/index.md

Co-authored-by: Dewi Roberts <dewi@goauthentik.io>
Signed-off-by: Dominic R <dominic@sdko.org>

* Update website/integrations/services/firezone/index.md

Co-authored-by: Dewi Roberts <dewi@goauthentik.io>
Signed-off-by: Dominic R <dominic@sdko.org>

* fix

Signed-off-by: Dominic R <dominic@sdko.org>

* wip: migr actual budget integration to new codeblock

* Replaced multilinecodeblocks with docusaurus style codeblocks

* Fixed linting and removed kbd and em tags from codeblock

---------

Signed-off-by: Dominic R <dominic@sdko.org>
Co-authored-by: Dewi Roberts <dewi@goauthentik.io>
2025-04-16 15:16:01 -05:00
51609d696d policies/geoip: fix result when only dynamic results are used (#14107) 2025-04-16 15:50:26 +00:00
c0d08df161 core: bump opentelemetry-api from 1.32.0 to v1.32.1 (#14102) 2025-04-16 15:50:10 +00:00
643a97f0a5 core: bump rsa from 4.9 to v4.9.1 (#14103) 2025-04-16 09:51:53 -04:00
155a31fd70 sources/oauth: introduce authorization code auth method (#14034)
Co-authored-by: Rsgm <rsgm123@gmail.com>
2025-04-16 13:00:08 +00:00
c6f9d5df7b core, web: update translations (#14096)
Co-authored-by: rissson <18313093+rissson@users.noreply.github.com>
2025-04-16 13:16:06 +02:00
ea85331a7e web/api: Fix Hoisted exports across entrypoints. Update Axios. (#14089)
* web/api: Fix issue where hoisted exports across entrypoints do not
order.

* web/api: Override OpenAPI transitive dep.
2025-04-15 20:09:41 +02:00
4f4c5253dd translate: Updates for file web/xliff/en.xlf in fr (#14091)
Translate web/xliff/en.xlf in fr

100% translated source file: 'web/xliff/en.xlf'
on 'fr'.

Co-authored-by: transifex-integration[bot] <43880903+transifex-integration[bot]@users.noreply.github.com>
2025-04-15 17:11:40 +00:00
83b2fc36df translate: Updates for file locale/en/LC_MESSAGES/django.po in fr (#14090)
Translate locale/en/LC_MESSAGES/django.po in fr

100% translated source file: 'locale/en/LC_MESSAGES/django.po'
on 'fr'.

Co-authored-by: transifex-integration[bot] <43880903+transifex-integration[bot]@users.noreply.github.com>
2025-04-15 17:08:09 +00:00
d99d2b8bdc translate: Updates for file locale/en/LC_MESSAGES/django.po in zh-Hans (#14087)
Translate django.po in zh-Hans

100% translated source file: 'django.po'
on 'zh-Hans'.

Co-authored-by: transifex-integration[bot] <43880903+transifex-integration[bot]@users.noreply.github.com>
2025-04-15 18:40:19 +02:00
9b96d04b3a translate: Updates for file web/xliff/en.xlf in zh-Hans (#14086)
Translate web/xliff/en.xlf in zh-Hans

100% translated source file: 'web/xliff/en.xlf'
on 'zh-Hans'.

Co-authored-by: transifex-integration[bot] <43880903+transifex-integration[bot]@users.noreply.github.com>
2025-04-15 18:40:02 +02:00
ca5b99eb16 translate: Updates for file web/xliff/en.xlf in zh_CN (#14084)
Translate web/xliff/en.xlf in zh_CN

100% translated source file: 'web/xliff/en.xlf'
on 'zh_CN'.

Co-authored-by: transifex-integration[bot] <43880903+transifex-integration[bot]@users.noreply.github.com>
2025-04-15 18:39:49 +02:00
4c1676e97c translate: Updates for file locale/en/LC_MESSAGES/django.po in zh_CN (#14083)
Translate locale/en/LC_MESSAGES/django.po in zh_CN

100% translated source file: 'locale/en/LC_MESSAGES/django.po'
on 'zh_CN'.

Co-authored-by: transifex-integration[bot] <43880903+transifex-integration[bot]@users.noreply.github.com>
2025-04-15 18:39:36 +02:00
81855cf2fe core: bump google-auth from 2.38.0 to v2.39.0 (#14076) 2025-04-15 08:32:54 -04:00
bd904027be core: bump sentry-sdk from 2.25.1 to v2.26.1 (#14079) 2025-04-15 08:32:14 -04:00
0ffc97db15 core: bump prompt-toolkit from 3.0.50 to v3.0.51 (#14078) 2025-04-15 08:31:41 -04:00
2c515b1e17 core: bump boto3 from 1.37.33 to v1.37.34 (#14074) 2025-04-15 08:31:12 -04:00
f8900fbaf3 core: bump msgraph-sdk from 1.27.0 to v1.28.0 (#14077) 2025-04-15 08:30:44 -04:00
0f4a98d9c6 website/docs: fix minor typo in working_with_policies.md (#14071)
Co-authored-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
2025-04-15 11:40:23 +00:00
8853f25b45 core, web: update translations (#14064)
Co-authored-by: rissson <18313093+rissson@users.noreply.github.com>
2025-04-15 13:26:02 +02:00
1c40f7b95a stages/authenticator_webauthn: Update FIDO MDS3 & Passkey aaguid blobs (#14065)
Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: authentik-automation[bot] <135050075+authentik-automation[bot]@users.noreply.github.com>
2025-04-15 13:25:13 +02:00
9b5d6ec1af core: bump goauthentik.io/api/v3 from 3.2025024.4 to 3.2025024.6 (#14069)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-04-15 13:24:28 +02:00
36d29a9ae1 Small fix for Actual-Budget wiki guide (#14066)
Remove ending slash from redirect uri

Signed-off-by: James Armstrong <32995055+jmarmstrong1207@users.noreply.github.com>
2025-04-15 09:43:59 +01:00
0606b1aba4 root: support db pool (#13534) 2025-04-14 16:05:31 +00:00
03d5dad867 rbac: add InitialPermissions (#13795)
* add `InitialPermissions` model to RBAC

This is a powerful construct between Permission and Role to set initial
permissions for newly created objects.

* use safer `request.user`

* fixup! use safer `request.user`

* force all self-defined serializers to descend from our custom one

See https://github.com/goauthentik/authentik/pull/10139

* reorganize initial permission assignment

* fixup! reorganize initial permission assignment
2025-04-14 17:55:49 +02:00
38a9e46af3 web: bump API Client version (#14058)
Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: authentik-automation[bot] <135050075+authentik-automation[bot]@users.noreply.github.com>
2025-04-14 17:24:47 +02:00
5eb848e376 core: Bump django from 5.0.14 to 5.1.8 (#14059)
Bump django from 5.0.14 to 5.1.8
2025-04-14 14:54:58 +00:00
61a293daad core: bump django-rest-framework from 3.14.0 to 3.16.0 (#14057)
upgrade `django-rest-framework` to `3.16.0`

The reverted commit is purely an optimization which unfortunately breaks authentik, specifically Blueprints. It adds `getattr(serializer.instance, field)` to a validator. If `field` is a `RelatedObject`, that invocation queries the database.

When authentik creates objects using Blueprints, it doesn't place related objects into the database before the validator tries to get them from there, so with the reverted commit, it produces `RelatedObjectDoesNotExist`.

Perhaps a long-term solution is to revise how Blueprints work, or perhaps it is to change upstream. But in the meantime, Django 5.0 support ended and upgrading to Django 5.1 requires an upgrade of `django-rest-framework` to `3.16.0`, hence this workaround.

See
- https://github.com/encode/django-rest-framework/pull/9154
- https://github.com/encode/django-rest-framework/issues/9358
- https://github.com/encode/django-rest-framework/pull/9482
- https://github.com/encode/django-rest-framework/pull/9483
2025-04-14 16:24:11 +02:00
edf3300944 policies/reputation: limit reputation score (#14008)
* add limits to reputation score

* limit reputation score limits

Upper to non-negative, Lower to non-positive

* simplify tests

* "fix" bandit false-positives

* move magic numbers to constants

Is it too much to ask for a world in which I can just import these
straight from Python?
2025-04-14 14:18:59 +00:00
5d9c40eac8 ci: fix api-py-publish by disabling poetry cache (#14010) 2025-04-14 16:18:31 +02:00
6ebfbcb66e core: bump goauthentik/fips-python from 3.12.9-slim-bookworm-fips to 3.12.10-slim-bookworm-fips (#14044) 2025-04-14 08:15:20 -06:00
bf0235c113 ci: add NPM packages publish (#13974)
Co-authored-by: Teffen Ellis <teffen@nirri.us>
2025-04-14 08:14:17 -06:00
895cd23b57 root: add packages/ to codeowners (#13975) 2025-04-14 08:05:09 -06:00
c908d9e95e providers/oauth2, rac: make sure tokens are revoked after session deletion (#14011) 2025-04-14 15:48:39 +02:00
a07fd8d54b core: bump multidict from 6.4.2 to v6.4.3 (#14051) 2025-04-14 13:26:50 +00:00
39a46a6dc4 core: bump uvicorn from 0.34.0 to v0.34.1 (#14056) 2025-04-14 13:26:10 +00:00
ad71960d77 core: bump typing-extensions from 4.13.1 to v4.13.2 (#14055) 2025-04-14 13:04:16 +00:00
2a384511f5 core: bump ruff from 0.11.4 to v0.11.5 (#14053) 2025-04-14 13:03:52 +00:00
4dcc104947 core: bump boto3 from 1.37.31 to v1.37.33 (#14045) 2025-04-14 09:02:05 -04:00
71fe526e47 core: bump opentelemetry-api from 1.31.1 to v1.32.0 (#14052) 2025-04-14 09:01:39 -04:00
03e3f516ac core: bump httpcore from 1.0.7 to v1.0.8 (#14050) 2025-04-14 13:00:58 +00:00
3b59333246 core: bump google-api-python-client from 2.166.0 to v2.167.0 (#14048) 2025-04-14 13:00:20 +00:00
4e800c14cb core: bump googleapis-common-protos from 1.69.2 to v1.70.0 (#14049) 2025-04-14 12:59:54 +00:00
789b29a3e7 core: bump debugpy from 1.8.13 to v1.8.14 (#14047) 2025-04-14 12:59:21 +00:00
857b6e63a0 root: prevent docker-compose up when secret key is missing (#14043) 2025-04-14 12:56:41 +00:00
edc937dd78 core: bump goauthentik.io/api/v3 from 3.2025024.2 to 3.2025024.4 (#14042)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-04-14 14:49:32 +02:00
d98b6f29d4 core: bump github.com/sethvargo/go-envconfig from 1.1.1 to 1.2.0 (#14041)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-04-14 14:49:17 +02:00
53ba2a0ca8 core, web: update translations (#14037)
Co-authored-by: rissson <18313093+rissson@users.noreply.github.com>
2025-04-14 14:40:38 +02:00
ae364292e6 website: Port WWW theme to docs site. Prep for package. (#13962)
Update sidebar.css

Signed-off-by: Teffen Ellis <592134+GirlBossRush@users.noreply.github.com>

website/docs: Update paths.

website/docs: Use package theme.
2025-04-12 01:31:57 +02:00
f15bc2df97 translate: Updates for file locale/en/LC_MESSAGES/django.po in nl [Manual Sync] (#14026)
Translate django.po in nl [Manual Sync]

80% of minimum 75% translated source file: 'django.po'
on 'nl'.

Sync of partially translated files: 
untranslated content is included with an empty translation 
or source language content depending on file format

Co-authored-by: transifex-integration[bot] <43880903+transifex-integration[bot]@users.noreply.github.com>
2025-04-11 15:55:33 +00:00
b27d49e55f translate: Updates for file web/xliff/en.xlf in fi [Manual Sync] (#14012)
Translate web/xliff/en.xlf in fi [Manual Sync]

95% of minimum 75% translated source file: 'web/xliff/en.xlf'
on 'fi'.

Sync of partially translated files: 
untranslated content is included with an empty translation 
or source language content depending on file format

Co-authored-by: transifex-integration[bot] <43880903+transifex-integration[bot]@users.noreply.github.com>
2025-04-11 17:38:37 +02:00
e0d2beb225 translate: Updates for file locale/en/LC_MESSAGES/django.po in de [Manual Sync] (#14020)
Translate django.po in de [Manual Sync]

96% of minimum 75% translated source file: 'django.po'
on 'de'.

Sync of partially translated files: 
untranslated content is included with an empty translation 
or source language content depending on file format

Co-authored-by: transifex-integration[bot] <43880903+transifex-integration[bot]@users.noreply.github.com>
2025-04-11 17:38:11 +02:00
2313b4755b translate: Updates for file locale/en/LC_MESSAGES/django.po in pt_BR [Manual Sync] (#14027)
Translate django.po in pt_BR [Manual Sync]

75% of minimum 75% translated source file: 'django.po'
on 'pt_BR'.

Sync of partially translated files: 
untranslated content is included with an empty translation 
or source language content depending on file format

Co-authored-by: transifex-integration[bot] <43880903+transifex-integration[bot]@users.noreply.github.com>
2025-04-11 17:37:55 +02:00
1cffadecb0 translate: Updates for file locale/en/LC_MESSAGES/django.po in it [Manual Sync] (#14024)
Translate django.po in it [Manual Sync]

99% of minimum 75% translated source file: 'django.po'
on 'it'.

Sync of partially translated files: 
untranslated content is included with an empty translation 
or source language content depending on file format

Co-authored-by: transifex-integration[bot] <43880903+transifex-integration[bot]@users.noreply.github.com>
2025-04-11 17:37:40 +02:00
5e163d6da1 translate: Updates for file locale/en/LC_MESSAGES/django.po in pl [Manual Sync] (#14025)
Translate django.po in pl [Manual Sync]

82% of minimum 75% translated source file: 'django.po'
on 'pl'.

Sync of partially translated files: 
untranslated content is included with an empty translation 
or source language content depending on file format

Co-authored-by: transifex-integration[bot] <43880903+transifex-integration[bot]@users.noreply.github.com>
2025-04-11 17:37:23 +02:00
0626e18674 translate: Updates for file locale/en/LC_MESSAGES/django.po in fi [Manual Sync] (#14023)
Translate django.po in fi [Manual Sync]

94% of minimum 75% translated source file: 'django.po'
on 'fi'.

Sync of partially translated files: 
untranslated content is included with an empty translation 
or source language content depending on file format

Co-authored-by: transifex-integration[bot] <43880903+transifex-integration[bot]@users.noreply.github.com>
2025-04-11 17:37:04 +02:00
e986a62a12 translate: Updates for file locale/en/LC_MESSAGES/django.po in zh_TW [Manual Sync] (#14031)
Translate django.po in zh_TW [Manual Sync]

79% of minimum 75% translated source file: 'django.po'
on 'zh_TW'.

Sync of partially translated files: 
untranslated content is included with an empty translation 
or source language content depending on file format

Co-authored-by: transifex-integration[bot] <43880903+transifex-integration[bot]@users.noreply.github.com>
2025-04-11 17:36:48 +02:00
e25afcb84a translate: Updates for file web/xliff/en.xlf in pl [Manual Sync] (#14015)
Translate web/xliff/en.xlf in pl [Manual Sync]

85% of minimum 75% translated source file: 'web/xliff/en.xlf'
on 'pl'.

Sync of partially translated files: 
untranslated content is included with an empty translation 
or source language content depending on file format

Co-authored-by: transifex-integration[bot] <43880903+transifex-integration[bot]@users.noreply.github.com>
2025-04-11 17:36:33 +02:00
bb95613104 translate: Updates for file web/xliff/en.xlf in zh_CN [Manual Sync] (#14017)
Translate web/xliff/en.xlf in zh_CN [Manual Sync]

99% of minimum 75% translated source file: 'web/xliff/en.xlf'
on 'zh_CN'.

Sync of partially translated files: 
untranslated content is included with an empty translation 
or source language content depending on file format

Co-authored-by: transifex-integration[bot] <43880903+transifex-integration[bot]@users.noreply.github.com>
2025-04-11 17:36:17 +02:00
89dfac2f57 translate: Updates for file web/xliff/en.xlf in it [Manual Sync] (#14016)
Translate web/xliff/en.xlf in it [Manual Sync]

99% of minimum 75% translated source file: 'web/xliff/en.xlf'
on 'it'.

Sync of partially translated files: 
untranslated content is included with an empty translation 
or source language content depending on file format

Co-authored-by: transifex-integration[bot] <43880903+transifex-integration[bot]@users.noreply.github.com>
2025-04-11 17:36:14 +02:00
31462b55e6 translate: Updates for file locale/en/LC_MESSAGES/django.po in zh_CN [Manual Sync] (#14028)
Translate django.po in zh_CN [Manual Sync]

99% of minimum 75% translated source file: 'django.po'
on 'zh_CN'.

Sync of partially translated files: 
untranslated content is included with an empty translation 
or source language content depending on file format

Co-authored-by: transifex-integration[bot] <43880903+transifex-integration[bot]@users.noreply.github.com>
2025-04-11 17:36:08 +02:00
60337c1cf0 translate: Updates for file locale/en/LC_MESSAGES/django.po in zh-Hans [Manual Sync] (#14029)
Translate django.po in zh-Hans [Manual Sync]

99% of minimum 75% translated source file: 'django.po'
on 'zh-Hans'.

Sync of partially translated files: 
untranslated content is included with an empty translation 
or source language content depending on file format

Co-authored-by: transifex-integration[bot] <43880903+transifex-integration[bot]@users.noreply.github.com>
2025-04-11 17:36:03 +02:00
343d3bb1fb translate: Updates for file locale/en/LC_MESSAGES/django.po in ru [Manual Sync] (#14032)
Translate django.po in ru [Manual Sync]

91% of minimum 75% translated source file: 'django.po'
on 'ru'.

Sync of partially translated files: 
untranslated content is included with an empty translation 
or source language content depending on file format

Co-authored-by: transifex-integration[bot] <43880903+transifex-integration[bot]@users.noreply.github.com>
2025-04-11 15:09:01 +00:00
11fe86c4f6 translate: Updates for file locale/en/LC_MESSAGES/django.po in fr [Manual Sync] (#14022)
Translate django.po in fr [Manual Sync]

99% of minimum 75% translated source file: 'django.po'
on 'fr'.

Sync of partially translated files: 
untranslated content is included with an empty translation 
or source language content depending on file format

Co-authored-by: transifex-integration[bot] <43880903+transifex-integration[bot]@users.noreply.github.com>
2025-04-11 15:07:16 +00:00
963ce085e4 translate: Updates for file locale/en/LC_MESSAGES/django.po in es [Manual Sync] (#14019)
Translate django.po in es [Manual Sync]

95% of minimum 75% translated source file: 'django.po'
on 'es'.

Sync of partially translated files: 
untranslated content is included with an empty translation 
or source language content depending on file format

Co-authored-by: transifex-integration[bot] <43880903+transifex-integration[bot]@users.noreply.github.com>
2025-04-11 15:06:47 +00:00
3642b89ab0 translate: Updates for file web/xliff/en.xlf in zh-Hans [Manual Sync] (#14021)
Translate en.xlf in zh-Hans [Manual Sync]

99% of minimum 75% translated source file: 'en.xlf'
on 'zh-Hans'.

Sync of partially translated files: 
untranslated content is included with an empty translation 
or source language content depending on file format

Co-authored-by: transifex-integration[bot] <43880903+transifex-integration[bot]@users.noreply.github.com>
2025-04-11 15:06:07 +00:00
8cfb371ed3 translate: Updates for file web/xliff/en.xlf in ru [Manual Sync] (#14013)
Translate web/xliff/en.xlf in ru [Manual Sync]

90% of minimum 75% translated source file: 'web/xliff/en.xlf'
on 'ru'.

Sync of partially translated files: 
untranslated content is included with an empty translation 
or source language content depending on file format

Co-authored-by: transifex-integration[bot] <43880903+transifex-integration[bot]@users.noreply.github.com>
2025-04-11 15:05:28 +00:00
6e74edb9f2 web: bump API Client version (#13972)
Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: authentik-automation[bot] <135050075+authentik-automation[bot]@users.noreply.github.com>
2025-04-11 14:32:58 +02:00
397905f8f0 translate: Updates for file web/xliff/en.xlf in fr [Manual Sync] (#13979)
* Translate web/xliff/en.xlf in fr [Manual Sync]

100% translated source file: 'web/xliff/en.xlf'
on 'fr'.

* Removing web/xliff/en.xlf in fr

99% of minimum 100% translated source file: 'web/xliff/en.xlf'
on 'fr'.

---------

Co-authored-by: transifex-integration[bot] <43880903+transifex-integration[bot]@users.noreply.github.com>
2025-04-11 14:22:54 +02:00
7fd35b1dfc sources/ldap: add source connections (#13796) 2025-04-11 12:07:18 +00:00
9ba03f5439 core: bump urllib3 from 2.3.0 to 2.4.0 (#14006)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
2025-04-11 12:00:56 +00:00
1139d6d27c translate: Updates for file web/xliff/en.xlf in zh-Hans [Manual Sync] (#13985)
* Translate en.xlf in zh-Hans [Manual Sync]

100% translated source file: 'en.xlf'
on 'zh-Hans'.

* Removing web/xliff/en.xlf in zh-Hans

99% of minimum 100% translated source file: 'web/xliff/en.xlf'
on 'zh-Hans'.

---------

Co-authored-by: transifex-integration[bot] <43880903+transifex-integration[bot]@users.noreply.github.com>
2025-04-11 11:58:56 +00:00
077fd966c2 translate: Updates for file locale/en/LC_MESSAGES/django.po in ru [Manual Sync] (#13992)
Translate django.po in ru [Manual Sync]

91% of minimum 60% translated source file: 'django.po'
on 'ru'.

Sync of partially translated files: 
untranslated content is included with an empty translation 
or source language content depending on file format

Co-authored-by: transifex-integration[bot] <43880903+transifex-integration[bot]@users.noreply.github.com>
2025-04-11 13:46:11 +02:00
bd41822a57 translate: Updates for file locale/en/LC_MESSAGES/django.po in de [Manual Sync] (#13986)
Translate django.po in de [Manual Sync]

96% of minimum 60% translated source file: 'django.po'
on 'de'.

Sync of partially translated files: 
untranslated content is included with an empty translation 
or source language content depending on file format

Co-authored-by: transifex-integration[bot] <43880903+transifex-integration[bot]@users.noreply.github.com>
2025-04-11 13:45:49 +02:00
dfd3d76434 translate: Updates for file locale/en/LC_MESSAGES/django.po in nl [Manual Sync] (#13991)
Translate django.po in nl [Manual Sync]

81% of minimum 60% translated source file: 'django.po'
on 'nl'.

Sync of partially translated files: 
untranslated content is included with an empty translation 
or source language content depending on file format

Co-authored-by: transifex-integration[bot] <43880903+transifex-integration[bot]@users.noreply.github.com>
2025-04-11 13:45:24 +02:00
397e98906d translate: Updates for file locale/en/LC_MESSAGES/django.po in es [Manual Sync] (#13987)
Translate django.po in es [Manual Sync]

95% of minimum 60% translated source file: 'django.po'
on 'es'.

Sync of partially translated files: 
untranslated content is included with an empty translation 
or source language content depending on file format

Co-authored-by: transifex-integration[bot] <43880903+transifex-integration[bot]@users.noreply.github.com>
2025-04-11 13:45:12 +02:00
65d8da8c64 translate: Updates for file locale/en/LC_MESSAGES/django.po in zh_TW [Manual Sync] (#13994)
Translate django.po in zh_TW [Manual Sync]

79% of minimum 60% translated source file: 'django.po'
on 'zh_TW'.

Sync of partially translated files: 
untranslated content is included with an empty translation 
or source language content depending on file format

Co-authored-by: transifex-integration[bot] <43880903+transifex-integration[bot]@users.noreply.github.com>
2025-04-11 13:44:55 +02:00
5b435297c5 translate: Updates for file locale/en/LC_MESSAGES/django.po in fi [Manual Sync] (#13988)
Translate django.po in fi [Manual Sync]

94% of minimum 60% translated source file: 'django.po'
on 'fi'.

Sync of partially translated files: 
untranslated content is included with an empty translation 
or source language content depending on file format

Co-authored-by: transifex-integration[bot] <43880903+transifex-integration[bot]@users.noreply.github.com>
2025-04-11 13:44:39 +02:00
f792fd42f6 translate: Updates for file web/xliff/en.xlf in fi [Manual Sync] (#13978)
Translate web/xliff/en.xlf in fi [Manual Sync]

95% of minimum 60% translated source file: 'web/xliff/en.xlf'
on 'fi'.

Sync of partially translated files: 
untranslated content is included with an empty translation 
or source language content depending on file format

Co-authored-by: transifex-integration[bot] <43880903+transifex-integration[bot]@users.noreply.github.com>
2025-04-11 13:44:32 +02:00
70c0fdd5fa translate: Updates for file locale/en/LC_MESSAGES/django.po in pl [Manual Sync] (#13989)
Translate django.po in pl [Manual Sync]

82% of minimum 60% translated source file: 'django.po'
on 'pl'.

Sync of partially translated files: 
untranslated content is included with an empty translation 
or source language content depending on file format

Co-authored-by: transifex-integration[bot] <43880903+transifex-integration[bot]@users.noreply.github.com>
2025-04-11 13:44:24 +02:00
9b636eba01 translate: Updates for file locale/en/LC_MESSAGES/django.po in pt_BR [Manual Sync] (#13990)
Translate django.po in pt_BR [Manual Sync]

75% of minimum 60% translated source file: 'django.po'
on 'pt_BR'.

Sync of partially translated files: 
untranslated content is included with an empty translation 
or source language content depending on file format

Co-authored-by: transifex-integration[bot] <43880903+transifex-integration[bot]@users.noreply.github.com>
2025-04-11 13:44:19 +02:00
a982224502 translate: Updates for file locale/en/LC_MESSAGES/django.po in tr [Manual Sync] (#13995)
Translate django.po in tr [Manual Sync]

91% of minimum 60% translated source file: 'django.po'
on 'tr'.

Sync of partially translated files: 
untranslated content is included with an empty translation 
or source language content depending on file format

Co-authored-by: transifex-integration[bot] <43880903+transifex-integration[bot]@users.noreply.github.com>
2025-04-11 13:44:13 +02:00
6a16cccb40 translate: Updates for file locale/en/LC_MESSAGES/django.po in ko [Manual Sync] (#13993)
Translate django.po in ko [Manual Sync]

68% of minimum 60% translated source file: 'django.po'
on 'ko'.

Sync of partially translated files: 
untranslated content is included with an empty translation 
or source language content depending on file format

Co-authored-by: transifex-integration[bot] <43880903+transifex-integration[bot]@users.noreply.github.com>
2025-04-11 13:43:49 +02:00
6dac91e2b4 core: bump github.com/getsentry/sentry-go from 0.31.1 to 0.32.0 (#14004)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-04-11 13:43:29 +02:00
3e2d0532d1 core: bump goauthentik.io/api/v3 from 3.2025024.1 to 3.2025024.2 (#14005)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-04-11 13:42:03 +02:00
4e1300650b core, web: update translations (#13999)
Co-authored-by: rissson <18313093+rissson@users.noreply.github.com>
2025-04-11 13:40:50 +02:00
06b3ed0c9c core: fix migrations (#14009) 2025-04-11 13:36:53 +02:00
395ad722b7 core: migrate all sessions to the database (#9736)
Signed-off-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
2025-04-11 09:10:55 +02:00
9917d81246 website/integrations: add openproject (#13838)
* Added scope mapping section

* Updated formatting

* Bolded UI elements

* Update website/integrations/services/openproject/index.md

Signed-off-by: Tana M Berry <tanamarieberry@yahoo.com>

* Update website/integrations/services/openproject/index.md

Co-authored-by: Tana M Berry <tanamarieberry@yahoo.com>
Signed-off-by: Dewi Roberts <dewi@goauthentik.io>

* Update website/integrations/services/openproject/index.md

Co-authored-by: Tana M Berry <tanamarieberry@yahoo.com>
Signed-off-by: Dewi Roberts <dewi@goauthentik.io>

* Update website/integrations/services/openproject/index.md

Co-authored-by: Tana M Berry <tanamarieberry@yahoo.com>
Signed-off-by: Dewi Roberts <dewi@goauthentik.io>

* Indented code block

---------

Signed-off-by: Tana M Berry <tanamarieberry@yahoo.com>
Signed-off-by: Dewi Roberts <dewi@goauthentik.io>
Co-authored-by: Tana M Berry <tanamarieberry@yahoo.com>
2025-04-10 15:52:00 -05:00
2a87687d34 website/integrations: add wazuh (#13776)
* Document explaining integration between authentik and knocknoc

* Clarified Knocknoc config

Signed-off-by: Dewi Roberts <dewi@goauthentik.io>

* Fixed typos

* Document mostly complete. Work to be done on Wazuh config section

* Completed the Wazuh config section

* Changed URL in Wazuh config

* typo

* Removed knocknoc doc

* Update website/integrations/services/wazuh/index.mdx

Co-authored-by: Dominic R <dominic@sdko.org>
Signed-off-by: Dewi Roberts <dewi@goauthentik.io>

* Update website/integrations/services/wazuh/index.mdx

Co-authored-by: Dominic R <dominic@sdko.org>
Signed-off-by: Dewi Roberts <dewi@goauthentik.io>

* Update website/integrations/services/wazuh/index.mdx

Co-authored-by: Dominic R <dominic@sdko.org>
Signed-off-by: Dewi Roberts <dewi@goauthentik.io>

* Update website/integrations/services/wazuh/index.mdx

Co-authored-by: Dominic R <dominic@sdko.org>
Signed-off-by: Dewi Roberts <dewi@goauthentik.io>

* Update website/integrations/services/wazuh/index.mdx

Co-authored-by: Dominic R <dominic@sdko.org>
Signed-off-by: Dewi Roberts <dewi@goauthentik.io>

* Multiline codeblocks and moved SAML metadata note to beginning of section.

* Update sidebarsIntegrations.js to remove knoknok

Signed-off-by: Dewi Roberts <dewi@goauthentik.io>

* Update website/integrations/services/wazuh/index.mdx

Co-authored-by: Dominic R <dominic@sdko.org>
Signed-off-by: Dewi Roberts <dewi@goauthentik.io>

* Update website/integrations/services/wazuh/index.mdx

Co-authored-by: Dominic R <dominic@sdko.org>
Signed-off-by: Dewi Roberts <dewi@goauthentik.io>

* Changed group creation section to cut down on repetition of the word "click"

* Update website/integrations/services/wazuh/index.mdx

Co-authored-by: Dominic R <dominic@sdko.org>
Signed-off-by: Dewi Roberts <dewi@goauthentik.io>

* Fixed mistake in the config.yml section and applied various suggestions from Dominic

* Fixed multilinecodeblocks and commands formatting

Signed-off-by: Dewi Roberts <dewi@goauthentik.io>

* Changed multiline codeblocks due to formatting issues.

* Clarified what run_as parameter does

* Update website/integrations/services/wazuh/index.mdx

Signed-off-by: Tana M Berry <tanamarieberry@yahoo.com>

* Update website/integrations/services/wazuh/index.mdx

Signed-off-by: Tana M Berry <tanamarieberry@yahoo.com>

* Update website/integrations/services/wazuh/index.mdx

Signed-off-by: Tana M Berry <tanamarieberry@yahoo.com>

* Update website/integrations/services/wazuh/index.mdx

Signed-off-by: Tana M Berry <tanamarieberry@yahoo.com>

* Update website/integrations/services/wazuh/index.mdx

Signed-off-by: Tana M Berry <tanamarieberry@yahoo.com>

* Update website/integrations/services/wazuh/index.mdx

Signed-off-by: Tana M Berry <tanamarieberry@yahoo.com>

* Fixed codeblock indenting and prettier issue

---------

Signed-off-by: Dewi Roberts <dewi@goauthentik.io>
Signed-off-by: Tana M Berry <tanamarieberry@yahoo.com>
Co-authored-by: Dominic R <dominic@sdko.org>
Co-authored-by: Tana M Berry <tanamarieberry@yahoo.com>
2025-04-10 15:43:02 -05:00
a726c2260a translate: Updates for file locale/en/LC_MESSAGES/django.po in zh-Hans [Manual Sync] (#13996)
Translate django.po in zh-Hans [Manual Sync]

99% of minimum 60% translated source file: 'django.po'
on 'zh-Hans'.

Sync of partially translated files: 
untranslated content is included with an empty translation 
or source language content depending on file format

Co-authored-by: transifex-integration[bot] <43880903+transifex-integration[bot]@users.noreply.github.com>
2025-04-10 15:38:46 +00:00
44e0bfd4ef website: dev docs: fix upper-case authentik (#13961)
Signed-off-by: Dominic R <dominic@sdko.org>
2025-04-10 16:06:17 +01:00
8d0b362c9c web: elements: Table: Fix table selection clearing behavior (#13959)
web: elements: Table: Fix table selection clearing and modal closing behavior

Closes https://github.com/goauthentik/authentik/issues/13831
2025-04-10 17:03:02 +02:00
327 changed files with 45878 additions and 12440 deletions

View File

@ -30,7 +30,6 @@ jobs:
uses: actions/setup-python@v5
with:
python-version-file: "pyproject.toml"
cache: "poetry"
- name: Generate API Client
run: make gen-client-py
- name: Publish package

View File

@ -0,0 +1,45 @@
name: authentik-packages-npm-publish
on:
push:
branches: [main]
paths:
- packages/docusaurus-config
- packages/eslint-config
- packages/prettier-config
- packages/tsconfig
workflow_dispatch:
jobs:
publish:
if: ${{ github.repository != 'goauthentik/authentik-internal' }}
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
package:
- docusaurus-config
- eslint-config
- prettier-config
- tsconfig
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 2
- uses: actions/setup-node@v4
with:
node-version-file: packages/${{ matrix.package }}/package.json
registry-url: "https://registry.npmjs.org"
- name: Get changed files
id: changed-files
uses: tj-actions/changed-files@ed68ef82c095e0d48ec87eccea555d944a631a4c
with:
files: |
packages/${{ matrix.package }}/package.json
- name: Publish package
if: steps.changed-files.outputs.any_changed == 'true'
working-directory: packages/${{ matrix.package}}
run: |
npm ci
npm run build
npm publish
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_PUBLISH_TOKEN }}

4
.gitignore vendored
View File

@ -11,6 +11,10 @@ local_settings.py
db.sqlite3
media
# Node
node_modules
# If your build process includes running collectstatic, then you probably don't need or want to include staticfiles/
# in your Git repository. Update and uncomment the following line accordingly.
# <django-project-name>/staticfiles/

47
.prettierignore Normal file
View File

@ -0,0 +1,47 @@
# Prettier Ignorefile
## Static Files
**/LICENSE
authentik/stages/**/*
## Build asset directories
coverage
dist
out
.docusaurus
website/docs/developer-docs/api/**/*
## Environment
*.env
## Secrets
*.secrets
## Yarn
.yarn/**/*
## Node
node_modules
coverage
## Configs
*.log
*.yaml
*.yml
# Templates
# TODO: Rename affected files to *.template.* or similar.
*.html
*.mdx
*.md
## Import order matters
poly.ts
src/locale-codes.ts
src/locales/
# Storybook
storybook-static/
.storybook/css-import-maps*

View File

@ -23,6 +23,8 @@ docker-compose.yml @goauthentik/infrastructure
Makefile @goauthentik/infrastructure
.editorconfig @goauthentik/infrastructure
CODEOWNERS @goauthentik/infrastructure
# Web packages
packages/ @goauthentik/frontend
# Web
web/ @goauthentik/frontend
tests/wdio/ @goauthentik/frontend

View File

@ -94,9 +94,9 @@ RUN --mount=type=secret,id=GEOIPUPDATE_ACCOUNT_ID \
/bin/sh -c "/usr/bin/entry.sh || echo 'Failed to get GeoIP database, disabling'; exit 0"
# Stage 5: Download uv
FROM ghcr.io/astral-sh/uv:0.6.14 AS uv
FROM ghcr.io/astral-sh/uv:0.6.16 AS uv
# Stage 6: Base python image
FROM ghcr.io/goauthentik/fips-python:3.12.9-slim-bookworm-fips AS python-base
FROM ghcr.io/goauthentik/fips-python:3.12.10-slim-bookworm-fips AS python-base
ENV VENV_PATH="/ak-root/.venv" \
PATH="/lifecycle:/ak-root/.venv/bin:$PATH" \

View File

@ -7,7 +7,7 @@ from rest_framework.exceptions import ValidationError
from rest_framework.fields import CharField, DateTimeField
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.serializers import ListSerializer, ModelSerializer
from rest_framework.serializers import ListSerializer
from rest_framework.viewsets import ModelViewSet
from authentik.blueprints.models import BlueprintInstance
@ -15,7 +15,7 @@ from authentik.blueprints.v1.importer import Importer
from authentik.blueprints.v1.oci import OCI_PREFIX
from authentik.blueprints.v1.tasks import apply_blueprint, blueprints_find_dict
from authentik.core.api.used_by import UsedByMixin
from authentik.core.api.utils import JSONDictField, PassiveSerializer
from authentik.core.api.utils import JSONDictField, ModelSerializer, PassiveSerializer
from authentik.rbac.decorators import permission_required

View File

@ -36,6 +36,7 @@ from authentik.core.models import (
GroupSourceConnection,
PropertyMapping,
Provider,
Session,
Source,
User,
UserSourceConnection,
@ -108,6 +109,7 @@ def excluded_models() -> list[type[Model]]:
Policy,
PolicyBindingModel,
# Classes that have other dependencies
Session,
AuthenticatedSession,
# Classes which are only internally managed
# FIXME: these shouldn't need to be explicitly listed, but rather based off of a mixin

View File

@ -5,6 +5,7 @@ from typing import TypedDict
from rest_framework import mixins
from rest_framework.fields import SerializerMethodField
from rest_framework.request import Request
from rest_framework.serializers import CharField, DateTimeField, IPAddressField
from rest_framework.viewsets import GenericViewSet
from ua_parser import user_agent_parser
@ -54,6 +55,11 @@ class UserAgentDict(TypedDict):
class AuthenticatedSessionSerializer(ModelSerializer):
"""AuthenticatedSession Serializer"""
expires = DateTimeField(source="session.expires", read_only=True)
last_ip = IPAddressField(source="session.last_ip", read_only=True)
last_user_agent = CharField(source="session.last_user_agent", read_only=True)
last_used = DateTimeField(source="session.last_used", read_only=True)
current = SerializerMethodField()
user_agent = SerializerMethodField()
geo_ip = SerializerMethodField()
@ -62,19 +68,19 @@ class AuthenticatedSessionSerializer(ModelSerializer):
def get_current(self, instance: AuthenticatedSession) -> bool:
"""Check if session is currently active session"""
request: Request = self.context["request"]
return request._request.session.session_key == instance.session_key
return request._request.session.session_key == instance.session.session_key
def get_user_agent(self, instance: AuthenticatedSession) -> UserAgentDict:
"""Get parsed user agent"""
return user_agent_parser.Parse(instance.last_user_agent)
return user_agent_parser.Parse(instance.session.last_user_agent)
def get_geo_ip(self, instance: AuthenticatedSession) -> GeoIPDict | None: # pragma: no cover
"""Get GeoIP Data"""
return GEOIP_CONTEXT_PROCESSOR.city_dict(instance.last_ip)
return GEOIP_CONTEXT_PROCESSOR.city_dict(instance.session.last_ip)
def get_asn(self, instance: AuthenticatedSession) -> ASNDict | None: # pragma: no cover
"""Get ASN Data"""
return ASN_CONTEXT_PROCESSOR.asn_dict(instance.last_ip)
return ASN_CONTEXT_PROCESSOR.asn_dict(instance.session.last_ip)
class Meta:
model = AuthenticatedSession
@ -90,6 +96,7 @@ class AuthenticatedSessionSerializer(ModelSerializer):
"last_used",
"expires",
]
extra_args = {"uuid": {"read_only": True}}
class AuthenticatedSessionViewSet(
@ -101,9 +108,10 @@ class AuthenticatedSessionViewSet(
):
"""AuthenticatedSession Viewset"""
queryset = AuthenticatedSession.objects.all()
lookup_field = "uuid"
queryset = AuthenticatedSession.objects.select_related("session").all()
serializer_class = AuthenticatedSessionSerializer
search_fields = ["user__username", "last_ip", "last_user_agent"]
filterset_fields = ["user__username", "last_ip", "last_user_agent"]
search_fields = ["user__username", "session__last_ip", "session__last_user_agent"]
filterset_fields = ["user__username", "session__last_ip", "session__last_user_agent"]
ordering = ["user__username"]
owner_field = "user"

View File

@ -1,14 +1,11 @@
"""User API Views"""
from datetime import timedelta
from importlib import import_module
from json import loads
from typing import Any
from django.conf import settings
from django.contrib.auth import update_session_auth_hash
from django.contrib.auth.models import Permission
from django.contrib.sessions.backends.base import SessionBase
from django.db.models.functions import ExtractHour
from django.db.transaction import atomic
from django.db.utils import IntegrityError
@ -72,8 +69,8 @@ from authentik.core.middleware import (
from authentik.core.models import (
USER_ATTRIBUTE_TOKEN_EXPIRING,
USER_PATH_SERVICE_ACCOUNT,
AuthenticatedSession,
Group,
Session,
Token,
TokenIntents,
User,
@ -92,7 +89,6 @@ from authentik.stages.email.tasks import send_mails
from authentik.stages.email.utils import TemplateEmailMessage
LOGGER = get_logger()
SessionStore: SessionBase = import_module(settings.SESSION_ENGINE).SessionStore
class UserGroupSerializer(ModelSerializer):
@ -776,10 +772,6 @@ class UserViewSet(UsedByMixin, ModelViewSet):
response = super().partial_update(request, *args, **kwargs)
instance: User = self.get_object()
if not instance.is_active:
sessions = AuthenticatedSession.objects.filter(user=instance)
session_ids = sessions.values_list("session_key", flat=True)
for session in session_ids:
SessionStore(session).delete()
sessions.delete()
Session.objects.filter(authenticatedsession__user=instance).delete()
LOGGER.debug("Deleted user's sessions", user=instance.username)
return response

View File

@ -20,6 +20,8 @@ from rest_framework.serializers import (
raise_errors_on_nested_writes,
)
from authentik.rbac.permissions import assign_initial_permissions
def is_dict(value: Any):
"""Ensure a value is a dictionary, useful for JSONFields"""
@ -29,6 +31,14 @@ def is_dict(value: Any):
class ModelSerializer(BaseModelSerializer):
def create(self, validated_data):
instance = super().create(validated_data)
request = self.context.get("request")
if request and hasattr(request, "user") and not request.user.is_anonymous:
assign_initial_permissions(request.user, instance)
return instance
def update(self, instance: Model, validated_data):
raise_errors_on_nested_writes("update", self, validated_data)

View File

@ -24,6 +24,15 @@ class InbuiltBackend(ModelBackend):
self.set_method("password", request)
return user
async def aauthenticate(
self, request: HttpRequest, username: str | None, password: str | None, **kwargs: Any
) -> User | None:
user = await super().aauthenticate(request, username=username, password=password, **kwargs)
if not user:
return None
self.set_method("password", request)
return user
def set_method(self, method: str, request: HttpRequest | None, **kwargs):
"""Set method data on current flow, if possbiel"""
if not request:

View File

@ -0,0 +1,15 @@
"""Change user type"""
from importlib import import_module
from django.conf import settings
from authentik.tenants.management import TenantCommand
class Command(TenantCommand):
"""Delete all sessions"""
def handle_per_tenant(self, **options):
engine = import_module(settings.SESSION_ENGINE)
engine.SessionStore.clear_expired()

View File

@ -2,9 +2,14 @@
from collections.abc import Callable
from contextvars import ContextVar
from functools import partial
from uuid import uuid4
from django.contrib.auth.models import AnonymousUser
from django.core.exceptions import ImproperlyConfigured
from django.http import HttpRequest, HttpResponse
from django.utils.deprecation import MiddlewareMixin
from django.utils.functional import SimpleLazyObject
from django.utils.translation import override
from sentry_sdk.api import set_tag
from structlog.contextvars import STRUCTLOG_KEY_PREFIX
@ -20,6 +25,40 @@ CTX_HOST = ContextVar[str | None](STRUCTLOG_KEY_PREFIX + "host", default=None)
CTX_AUTH_VIA = ContextVar[str | None](STRUCTLOG_KEY_PREFIX + KEY_AUTH_VIA, default=None)
def get_user(request):
if not hasattr(request, "_cached_user"):
user = None
if (authenticated_session := request.session.get("authenticatedsession", None)) is not None:
user = authenticated_session.user
request._cached_user = user or AnonymousUser()
return request._cached_user
async def aget_user(request):
if not hasattr(request, "_cached_user"):
user = None
if (
authenticated_session := await request.session.aget("authenticatedsession", None)
) is not None:
user = authenticated_session.user
request._cached_user = user or AnonymousUser()
return request._cached_user
class AuthenticationMiddleware(MiddlewareMixin):
def process_request(self, request):
if not hasattr(request, "session"):
raise ImproperlyConfigured(
"The Django authentication middleware requires session "
"middleware to be installed. Edit your MIDDLEWARE setting to "
"insert "
"'authentik.root.middleware.SessionMiddleware' before "
"'authentik.core.middleware.AuthenticationMiddleware'."
)
request.user = SimpleLazyObject(lambda: get_user(request))
request.auser = partial(aget_user, request)
class ImpersonateMiddleware:
"""Middleware to impersonate users"""

View File

@ -0,0 +1,238 @@
# Generated by Django 5.0.11 on 2025-01-27 12:58
import uuid
import pickle # nosec
from django.core import signing
from django.contrib.auth import BACKEND_SESSION_KEY, HASH_SESSION_KEY, SESSION_KEY
from django.db import migrations, models
import django.db.models.deletion
from django.conf import settings
from django.contrib.sessions.backends.cache import KEY_PREFIX
from django.utils.timezone import now, timedelta
from authentik.lib.migrations import progress_bar
from authentik.root.middleware import ClientIPMiddleware
SESSION_CACHE_ALIAS = "default"
class PickleSerializer:
"""
Simple wrapper around pickle to be used in signing.dumps()/loads() and
cache backends.
"""
def __init__(self, protocol=None):
self.protocol = pickle.HIGHEST_PROTOCOL if protocol is None else protocol
def dumps(self, obj):
"""Pickle data to be stored in redis"""
return pickle.dumps(obj, self.protocol)
def loads(self, data):
"""Unpickle data to be loaded from redis"""
return pickle.loads(data) # nosec
def _migrate_session(
apps,
db_alias,
session_key,
session_data,
expires,
):
Session = apps.get_model("authentik_core", "Session")
OldAuthenticatedSession = apps.get_model("authentik_core", "OldAuthenticatedSession")
AuthenticatedSession = apps.get_model("authentik_core", "AuthenticatedSession")
old_auth_session = (
OldAuthenticatedSession.objects.using(db_alias).filter(session_key=session_key).first()
)
args = {
"session_key": session_key,
"expires": expires,
"last_ip": ClientIPMiddleware.default_ip,
"last_user_agent": "",
"session_data": {},
}
for k, v in session_data.items():
if k == "authentik/stages/user_login/last_ip":
args["last_ip"] = v
elif k in ["last_user_agent", "last_used"]:
args[k] = v
elif args in [SESSION_KEY, BACKEND_SESSION_KEY, HASH_SESSION_KEY]:
pass
else:
args["session_data"][k] = v
if old_auth_session:
args["last_user_agent"] = old_auth_session.last_user_agent
args["last_used"] = old_auth_session.last_used
args["session_data"] = pickle.dumps(args["session_data"])
session = Session.objects.using(db_alias).create(**args)
if old_auth_session:
AuthenticatedSession.objects.using(db_alias).create(
session=session,
user=old_auth_session.user,
)
def migrate_redis_sessions(apps, schema_editor):
from django.core.cache import caches
db_alias = schema_editor.connection.alias
cache = caches[SESSION_CACHE_ALIAS]
# Not a redis cache, skipping
if not hasattr(cache, "keys"):
return
print("\nMigrating Redis sessions to database, this might take a couple of minutes...")
for key, session_data in progress_bar(cache.get_many(cache.keys(f"{KEY_PREFIX}*")).items()):
_migrate_session(
apps=apps,
db_alias=db_alias,
session_key=key.removeprefix(KEY_PREFIX),
session_data=session_data,
expires=now() + timedelta(seconds=cache.ttl(key)),
)
def migrate_database_sessions(apps, schema_editor):
DjangoSession = apps.get_model("sessions", "Session")
db_alias = schema_editor.connection.alias
print("\nMigration database sessions, this might take a couple of minutes...")
for django_session in progress_bar(DjangoSession.objects.using(db_alias).all()):
session_data = signing.loads(
django_session.session_data,
salt="django.contrib.sessions.SessionStore",
serializer=PickleSerializer,
)
_migrate_session(
apps=apps,
db_alias=db_alias,
session_key=django_session.session_key,
session_data=session_data,
expires=django_session.expire_date,
)
class Migration(migrations.Migration):
dependencies = [
("sessions", "0001_initial"),
("authentik_core", "0045_rename_new_identifier_usersourceconnection_identifier_and_more"),
("authentik_providers_oauth2", "0027_accesstoken_authentik_p_expires_9f24a5_idx_and_more"),
("authentik_providers_rac", "0006_connectiontoken_authentik_p_expires_91f148_idx_and_more"),
]
operations = [
# Rename AuthenticatedSession to OldAuthenticatedSession
migrations.RenameModel(
old_name="AuthenticatedSession",
new_name="OldAuthenticatedSession",
),
migrations.RenameIndex(
model_name="oldauthenticatedsession",
new_name="authentik_c_expires_cf4f72_idx",
old_name="authentik_c_expires_08251d_idx",
),
migrations.RenameIndex(
model_name="oldauthenticatedsession",
new_name="authentik_c_expirin_c1f17f_idx",
old_name="authentik_c_expirin_9cd839_idx",
),
migrations.RenameIndex(
model_name="oldauthenticatedsession",
new_name="authentik_c_expirin_e04f5d_idx",
old_name="authentik_c_expirin_195a84_idx",
),
migrations.RenameIndex(
model_name="oldauthenticatedsession",
new_name="authentik_c_session_a44819_idx",
old_name="authentik_c_session_d0f005_idx",
),
migrations.RunSQL(
sql="ALTER INDEX authentik_core_authenticatedsession_user_id_5055b6cf RENAME TO authentik_core_oldauthenticatedsession_user_id_5055b6cf",
reverse_sql="ALTER INDEX authentik_core_oldauthenticatedsession_user_id_5055b6cf RENAME TO authentik_core_authenticatedsession_user_id_5055b6cf",
),
# Create new Session and AuthenticatedSession models
migrations.CreateModel(
name="Session",
fields=[
(
"session_key",
models.CharField(
max_length=40, primary_key=True, serialize=False, verbose_name="session key"
),
),
("expires", models.DateTimeField(default=None, null=True)),
("expiring", models.BooleanField(default=True)),
("session_data", models.BinaryField(verbose_name="session data")),
("last_ip", models.GenericIPAddressField()),
("last_user_agent", models.TextField(blank=True)),
("last_used", models.DateTimeField(auto_now=True)),
],
options={
"default_permissions": [],
"verbose_name": "Session",
"verbose_name_plural": "Sessions",
},
),
migrations.AddIndex(
model_name="session",
index=models.Index(fields=["expires"], name="authentik_c_expires_d2f607_idx"),
),
migrations.AddIndex(
model_name="session",
index=models.Index(fields=["expiring"], name="authentik_c_expirin_7c2cfb_idx"),
),
migrations.AddIndex(
model_name="session",
index=models.Index(
fields=["expiring", "expires"], name="authentik_c_expirin_1ab2e4_idx"
),
),
migrations.AddIndex(
model_name="session",
index=models.Index(
fields=["expires", "session_key"], name="authentik_c_expires_c49143_idx"
),
),
migrations.CreateModel(
name="AuthenticatedSession",
fields=[
(
"session",
models.OneToOneField(
on_delete=django.db.models.deletion.CASCADE,
primary_key=True,
serialize=False,
to="authentik_core.session",
),
),
("uuid", models.UUIDField(default=uuid.uuid4, unique=True)),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL
),
),
],
options={
"verbose_name": "Authenticated Session",
"verbose_name_plural": "Authenticated Sessions",
},
),
migrations.RunPython(
code=migrate_redis_sessions,
reverse_code=migrations.RunPython.noop,
),
migrations.RunPython(
code=migrate_database_sessions,
reverse_code=migrations.RunPython.noop,
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 5.0.11 on 2025-01-27 13:02
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("authentik_core", "0046_session_and_more"),
("authentik_providers_rac", "0007_migrate_session"),
("authentik_providers_oauth2", "0028_migrate_session"),
]
operations = [
migrations.DeleteModel(
name="OldAuthenticatedSession",
),
]

View File

@ -1,6 +1,7 @@
"""authentik core models"""
from datetime import datetime
from enum import StrEnum
from hashlib import sha256
from typing import Any, Optional, Self
from uuid import uuid4
@ -9,6 +10,7 @@ from deepmerge import always_merger
from django.contrib.auth.hashers import check_password
from django.contrib.auth.models import AbstractUser
from django.contrib.auth.models import UserManager as DjangoUserManager
from django.contrib.sessions.base_session import AbstractBaseSession
from django.db import models
from django.db.models import Q, QuerySet, options
from django.db.models.constants import LOOKUP_SEP
@ -646,19 +648,30 @@ class SourceUserMatchingModes(models.TextChoices):
"""Different modes a source can handle new/returning users"""
IDENTIFIER = "identifier", _("Use the source-specific identifier")
EMAIL_LINK = "email_link", _(
"Link to a user with identical email address. Can have security implications "
"when a source doesn't validate email addresses."
EMAIL_LINK = (
"email_link",
_(
"Link to a user with identical email address. Can have security implications "
"when a source doesn't validate email addresses."
),
)
EMAIL_DENY = "email_deny", _(
"Use the user's email address, but deny enrollment when the email address already exists."
EMAIL_DENY = (
"email_deny",
_(
"Use the user's email address, but deny enrollment when the email address already "
"exists."
),
)
USERNAME_LINK = "username_link", _(
"Link to a user with identical username. Can have security implications "
"when a username is used with another source."
USERNAME_LINK = (
"username_link",
_(
"Link to a user with identical username. Can have security implications "
"when a username is used with another source."
),
)
USERNAME_DENY = "username_deny", _(
"Use the user's username, but deny enrollment when the username already exists."
USERNAME_DENY = (
"username_deny",
_("Use the user's username, but deny enrollment when the username already exists."),
)
@ -666,12 +679,16 @@ class SourceGroupMatchingModes(models.TextChoices):
"""Different modes a source can handle new/returning groups"""
IDENTIFIER = "identifier", _("Use the source-specific identifier")
NAME_LINK = "name_link", _(
"Link to a group with identical name. Can have security implications "
"when a group name is used with another source."
NAME_LINK = (
"name_link",
_(
"Link to a group with identical name. Can have security implications "
"when a group name is used with another source."
),
)
NAME_DENY = "name_deny", _(
"Use the group name, but deny enrollment when the name already exists."
NAME_DENY = (
"name_deny",
_("Use the group name, but deny enrollment when the name already exists."),
)
@ -730,8 +747,7 @@ class Source(ManagedModel, SerializerModel, PolicyBindingModel):
choices=SourceGroupMatchingModes.choices,
default=SourceGroupMatchingModes.IDENTIFIER,
help_text=_(
"How the source determines if an existing group should be used or "
"a new group created."
"How the source determines if an existing group should be used or a new group created."
),
)
@ -1012,45 +1028,75 @@ class PropertyMapping(SerializerModel, ManagedModel):
verbose_name_plural = _("Property Mappings")
class AuthenticatedSession(ExpiringModel):
"""Additional session class for authenticated users. Augments the standard django session
to achieve the following:
- Make it queryable by user
- Have a direct connection to user objects
- Allow users to view their own sessions and terminate them
- Save structured and well-defined information.
"""
class Session(ExpiringModel, AbstractBaseSession):
"""User session with extra fields for fast access"""
uuid = models.UUIDField(default=uuid4, primary_key=True)
# Remove upstream field because we're using our own ExpiringModel
expire_date = None
session_data = models.BinaryField(_("session data"))
session_key = models.CharField(max_length=40)
user = models.ForeignKey(User, on_delete=models.CASCADE)
last_ip = models.TextField()
# Keep in sync with Session.Keys
last_ip = models.GenericIPAddressField()
last_user_agent = models.TextField(blank=True)
last_used = models.DateTimeField(auto_now=True)
class Meta:
verbose_name = _("Session")
verbose_name_plural = _("Sessions")
indexes = ExpiringModel.Meta.indexes + [
models.Index(fields=["expires", "session_key"]),
]
default_permissions = []
def __str__(self):
return self.session_key
class Keys(StrEnum):
"""
Keys to be set with the session interface for the fields above to be updated.
If a field is added here that needs to be initialized when the session is initialized,
it must also be reflected in authentik.root.middleware.SessionMiddleware.process_request
and in authentik.core.sessions.SessionStore.__init__
"""
LAST_IP = "last_ip"
LAST_USER_AGENT = "last_user_agent"
LAST_USED = "last_used"
@classmethod
def get_session_store_class(cls):
from authentik.core.sessions import SessionStore
return SessionStore
def get_decoded(self):
raise NotImplementedError
class AuthenticatedSession(SerializerModel):
session = models.OneToOneField(Session, on_delete=models.CASCADE, primary_key=True)
# We use the session as primary key, but we need the API to be able to reference
# this object uniquely without exposing the session key
uuid = models.UUIDField(default=uuid4, unique=True)
user = models.ForeignKey(User, on_delete=models.CASCADE)
class Meta:
verbose_name = _("Authenticated Session")
verbose_name_plural = _("Authenticated Sessions")
indexes = ExpiringModel.Meta.indexes + [
models.Index(fields=["session_key"]),
]
def __str__(self) -> str:
return f"Authenticated Session {self.session_key[:10]}"
return f"Authenticated Session {str(self.pk)[:10]}"
@staticmethod
def from_request(request: HttpRequest, user: User) -> Optional["AuthenticatedSession"]:
"""Create a new session from a http request"""
from authentik.root.middleware import ClientIPMiddleware
if not hasattr(request, "session") or not request.session.session_key:
if not hasattr(request, "session") or not request.session.exists(
request.session.session_key
):
return None
return AuthenticatedSession(
session_key=request.session.session_key,
session=Session.objects.filter(session_key=request.session.session_key).first(),
user=user,
last_ip=ClientIPMiddleware.get_client_ip(request),
last_user_agent=request.META.get("HTTP_USER_AGENT", ""),
expires=request.session.get_expiry_date(),
)

168
authentik/core/sessions.py Normal file
View File

@ -0,0 +1,168 @@
"""authentik sessions engine"""
import pickle # nosec
from django.contrib.auth import BACKEND_SESSION_KEY, HASH_SESSION_KEY, SESSION_KEY
from django.contrib.sessions.backends.db import SessionStore as SessionBase
from django.core.exceptions import SuspiciousOperation
from django.utils import timezone
from django.utils.functional import cached_property
from structlog.stdlib import get_logger
from authentik.root.middleware import ClientIPMiddleware
LOGGER = get_logger()
class SessionStore(SessionBase):
def __init__(self, session_key=None, last_ip=None, last_user_agent=""):
super().__init__(session_key)
self._create_kwargs = {
"last_ip": last_ip or ClientIPMiddleware.default_ip,
"last_user_agent": last_user_agent,
}
@classmethod
def get_model_class(cls):
from authentik.core.models import Session
return Session
@cached_property
def model_fields(self):
return [k.value for k in self.model.Keys]
def _get_session_from_db(self):
try:
return (
self.model.objects.select_related(
"authenticatedsession",
"authenticatedsession__user",
)
.prefetch_related(
"authenticatedsession__user__groups",
"authenticatedsession__user__user_permissions",
)
.get(
session_key=self.session_key,
expires__gt=timezone.now(),
)
)
except (self.model.DoesNotExist, SuspiciousOperation) as exc:
if isinstance(exc, SuspiciousOperation):
LOGGER.warning(str(exc))
self._session_key = None
async def _aget_session_from_db(self):
try:
return (
await self.model.objects.select_related(
"authenticatedsession",
"authenticatedsession__user",
)
.prefetch_related(
"authenticatedsession__user__groups",
"authenticatedsession__user__user_permissions",
)
.aget(
session_key=self.session_key,
expires__gt=timezone.now(),
)
)
except (self.model.DoesNotExist, SuspiciousOperation) as exc:
if isinstance(exc, SuspiciousOperation):
LOGGER.warning(str(exc))
self._session_key = None
def encode(self, session_dict):
return pickle.dumps(session_dict, protocol=pickle.HIGHEST_PROTOCOL)
def decode(self, session_data):
try:
return pickle.loads(session_data) # nosec
except pickle.PickleError:
# ValueError, unpickling exceptions. If any of these happen, just return an empty
# dictionary (an empty session)
pass
return {}
def load(self):
s = self._get_session_from_db()
if s:
return {
"authenticatedsession": getattr(s, "authenticatedsession", None),
**{k: getattr(s, k) for k in self.model_fields},
**self.decode(s.session_data),
}
else:
return {}
async def aload(self):
s = await self._aget_session_from_db()
if s:
return {
"authenticatedsession": getattr(s, "authenticatedsession", None),
**{k: getattr(s, k) for k in self.model_fields},
**self.decode(s.session_data),
}
else:
return {}
def create_model_instance(self, data):
args = {
"session_key": self._get_or_create_session_key(),
"expires": self.get_expiry_date(),
"session_data": {},
**self._create_kwargs,
}
for k, v in data.items():
# Don't save:
# - unused auth data
# - related models
if k in [SESSION_KEY, BACKEND_SESSION_KEY, HASH_SESSION_KEY, "authenticatedsession"]:
pass
elif k in self.model_fields:
args[k] = v
else:
args["session_data"][k] = v
args["session_data"] = self.encode(args["session_data"])
return self.model(**args)
async def acreate_model_instance(self, data):
args = {
"session_key": await self._aget_or_create_session_key(),
"expires": await self.aget_expiry_date(),
"session_data": {},
**self._create_kwargs,
}
for k, v in data.items():
# Don't save:
# - unused auth data
# - related models
if k in [SESSION_KEY, BACKEND_SESSION_KEY, HASH_SESSION_KEY, "authenticatedsession"]:
pass
elif k in self.model_fields:
args[k] = v
else:
args["session_data"][k] = v
args["session_data"] = self.encode(args["session_data"])
return self.model(**args)
@classmethod
def clear_expired(cls):
cls.get_model_class().objects.filter(expires__lt=timezone.now()).delete()
@classmethod
async def aclear_expired(cls):
await cls.get_model_class().objects.filter(expires__lt=timezone.now()).adelete()
def cycle_key(self):
data = self._session
key = self.session_key
self.create()
self._session_cache = data
if key:
self.delete(key)
if (authenticated_session := data.get("authenticatedsession")) is not None:
authenticated_session.session_id = self.session_key
authenticated_session.save(force_insert=True)

View File

@ -1,14 +1,10 @@
"""authentik core signals"""
from importlib import import_module
from django.conf import settings
from django.contrib.auth.signals import user_logged_in, user_logged_out
from django.contrib.sessions.backends.base import SessionBase
from django.contrib.auth.signals import user_logged_in
from django.core.cache import cache
from django.core.signals import Signal
from django.db.models import Model
from django.db.models.signals import post_save, pre_delete, pre_save
from django.db.models.signals import post_delete, post_save, pre_save
from django.dispatch import receiver
from django.http.request import HttpRequest
from structlog.stdlib import get_logger
@ -18,6 +14,7 @@ from authentik.core.models import (
AuthenticatedSession,
BackchannelProvider,
ExpiringModel,
Session,
User,
default_token_duration,
)
@ -28,7 +25,6 @@ password_changed = Signal()
login_failed = Signal()
LOGGER = get_logger()
SessionStore: SessionBase = import_module(settings.SESSION_ENGINE).SessionStore
@receiver(post_save, sender=Application)
@ -53,18 +49,10 @@ def user_logged_in_session(sender, request: HttpRequest, user: User, **_):
session.save()
@receiver(user_logged_out)
def user_logged_out_session(sender, request: HttpRequest, user: User, **_):
"""Delete AuthenticatedSession if it exists"""
if not request.session or not request.session.session_key:
return
AuthenticatedSession.objects.filter(session_key=request.session.session_key).delete()
@receiver(pre_delete, sender=AuthenticatedSession)
@receiver(post_delete, sender=AuthenticatedSession)
def authenticated_session_delete(sender: type[Model], instance: "AuthenticatedSession", **_):
"""Delete session when authenticated session is deleted"""
SessionStore(instance.session_key).delete()
Session.objects.filter(session_key=instance.pk).delete()
@receiver(pre_save)

View File

@ -2,22 +2,16 @@
from datetime import datetime, timedelta
from django.conf import ImproperlyConfigured
from django.contrib.sessions.backends.cache import KEY_PREFIX
from django.contrib.sessions.backends.db import SessionStore as DBSessionStore
from django.core.cache import cache
from django.utils.timezone import now
from structlog.stdlib import get_logger
from authentik.core.models import (
USER_ATTRIBUTE_EXPIRES,
USER_ATTRIBUTE_GENERATED,
AuthenticatedSession,
ExpiringModel,
User,
)
from authentik.events.system_tasks import SystemTask, TaskStatus, prefill_task
from authentik.lib.config import CONFIG
from authentik.root.celery import CELERY_APP
LOGGER = get_logger()
@ -38,40 +32,6 @@ def clean_expired_models(self: SystemTask):
obj.expire_action()
LOGGER.debug("Expired models", model=cls, amount=amount)
messages.append(f"Expired {amount} {cls._meta.verbose_name_plural}")
# Special case
amount = 0
for session in AuthenticatedSession.objects.all():
match CONFIG.get("session_storage", "cache"):
case "cache":
cache_key = f"{KEY_PREFIX}{session.session_key}"
value = None
try:
value = cache.get(cache_key)
except Exception as exc:
LOGGER.debug("Failed to get session from cache", exc=exc)
if not value:
session.delete()
amount += 1
case "db":
if not (
DBSessionStore.get_model_class()
.objects.filter(session_key=session.session_key, expire_date__gt=now())
.exists()
):
session.delete()
amount += 1
case _:
# Should never happen, as we check for other values in authentik/root/settings.py
raise ImproperlyConfigured(
"Invalid session_storage setting, allowed values are db and cache"
)
if CONFIG.get("session_storage", "cache") == "db":
DBSessionStore.clear_expired()
LOGGER.debug("Expired sessions", model=AuthenticatedSession, amount=amount)
messages.append(f"Expired {amount} {AuthenticatedSession._meta.verbose_name_plural}")
self.set_status(TaskStatus.SUCCESSFUL, *messages)

View File

@ -1,9 +1,17 @@
"""Test API Utils"""
from rest_framework.exceptions import ValidationError
from rest_framework.serializers import (
HyperlinkedModelSerializer,
)
from rest_framework.serializers import (
ModelSerializer as BaseModelSerializer,
)
from rest_framework.test import APITestCase
from authentik.core.api.utils import ModelSerializer as CustomModelSerializer
from authentik.core.api.utils import is_dict
from authentik.lib.utils.reflection import all_subclasses
class TestAPIUtils(APITestCase):
@ -14,3 +22,14 @@ class TestAPIUtils(APITestCase):
self.assertIsNone(is_dict({}))
with self.assertRaises(ValidationError):
is_dict("foo")
def test_all_serializers_descend_from_custom(self):
"""Test that every serializer we define descends from our own ModelSerializer"""
# Weirdly, there's only one serializer in `rest_framework` which descends from
# ModelSerializer: HyperlinkedModelSerializer
expected = {CustomModelSerializer, HyperlinkedModelSerializer}
actual = set(all_subclasses(BaseModelSerializer)) - set(
all_subclasses(CustomModelSerializer)
)
self.assertEqual(expected, actual)

View File

@ -5,7 +5,7 @@ from json import loads
from django.urls.base import reverse
from rest_framework.test import APITestCase
from authentik.core.models import User
from authentik.core.models import AuthenticatedSession, Session, User
from authentik.core.tests.utils import create_test_admin_user
@ -30,3 +30,18 @@ class TestAuthenticatedSessionsAPI(APITestCase):
self.assertEqual(response.status_code, 200)
body = loads(response.content.decode())
self.assertEqual(body["pagination"]["count"], 1)
def test_delete(self):
"""Test deletion"""
self.client.force_login(self.user)
self.assertEqual(AuthenticatedSession.objects.all().count(), 1)
self.assertEqual(Session.objects.all().count(), 1)
response = self.client.delete(
reverse(
"authentik_api:authenticatedsession-detail",
kwargs={"uuid": AuthenticatedSession.objects.first().uuid},
)
)
self.assertEqual(response.status_code, 204)
self.assertEqual(AuthenticatedSession.objects.all().count(), 0)
self.assertEqual(Session.objects.all().count(), 0)

View File

@ -13,7 +13,10 @@ from authentik.core.models import (
TokenIntents,
User,
)
from authentik.core.tasks import clean_expired_models, clean_temporary_users
from authentik.core.tasks import (
clean_expired_models,
clean_temporary_users,
)
from authentik.core.tests.utils import create_test_admin_user
from authentik.lib.generators import generate_id

View File

@ -3,8 +3,6 @@
from datetime import datetime
from json import loads
from django.contrib.sessions.backends.cache import KEY_PREFIX
from django.core.cache import cache
from django.urls.base import reverse
from rest_framework.test import APITestCase
@ -12,6 +10,7 @@ from authentik.brands.models import Brand
from authentik.core.models import (
USER_ATTRIBUTE_TOKEN_EXPIRING,
AuthenticatedSession,
Session,
Token,
User,
UserTypes,
@ -381,12 +380,15 @@ class TestUsersAPI(APITestCase):
"""Ensure sessions are deleted when a user is deactivated"""
user = create_test_admin_user()
session_id = generate_id()
AuthenticatedSession.objects.create(
user=user,
session = Session.objects.create(
session_key=session_id,
last_ip="",
last_ip="255.255.255.255",
last_user_agent="",
)
AuthenticatedSession.objects.create(
session=session,
user=user,
)
cache.set(KEY_PREFIX + session_id, "foo")
self.client.force_login(self.admin)
response = self.client.patch(
@ -397,5 +399,7 @@ class TestUsersAPI(APITestCase):
)
self.assertEqual(response.status_code, 200)
self.assertIsNone(cache.get(KEY_PREFIX + session_id))
self.assertFalse(AuthenticatedSession.objects.filter(session_key=session_id).exists())
self.assertFalse(Session.objects.filter(session_key=session_id).exists())
self.assertFalse(
AuthenticatedSession.objects.filter(session__session_key=session_id).exists()
)

View File

@ -1,7 +1,5 @@
"""authentik URL Configuration"""
from channels.auth import AuthMiddleware
from channels.sessions import CookieMiddleware
from django.conf import settings
from django.contrib.auth.decorators import login_required
from django.urls import path
@ -29,7 +27,7 @@ from authentik.core.views.interface import (
RootRedirectView,
)
from authentik.flows.views.interface import FlowInterfaceView
from authentik.root.asgi_middleware import SessionMiddleware
from authentik.root.asgi_middleware import AuthMiddlewareStack
from authentik.root.messages.consumer import MessageConsumer
from authentik.root.middleware import ChannelsLoggingMiddleware
@ -99,9 +97,7 @@ api_urlpatterns = [
websocket_urlpatterns = [
path(
"ws/client/",
ChannelsLoggingMiddleware(
CookieMiddleware(SessionMiddleware(AuthMiddleware(MessageConsumer.as_asgi())))
),
ChannelsLoggingMiddleware(AuthMiddlewareStack(MessageConsumer.as_asgi())),
),
]

View File

@ -0,0 +1,27 @@
from rest_framework.viewsets import ModelViewSet
from authentik.core.api.used_by import UsedByMixin
from authentik.enterprise.api import EnterpriseRequiredMixin
from authentik.enterprise.policies.unique_password.models import UniquePasswordPolicy
from authentik.policies.api.policies import PolicySerializer
class UniquePasswordPolicySerializer(EnterpriseRequiredMixin, PolicySerializer):
"""Password Uniqueness Policy Serializer"""
class Meta:
model = UniquePasswordPolicy
fields = PolicySerializer.Meta.fields + [
"password_field",
"num_historical_passwords",
]
class UniquePasswordPolicyViewSet(UsedByMixin, ModelViewSet):
"""Password Uniqueness Policy Viewset"""
queryset = UniquePasswordPolicy.objects.all()
serializer_class = UniquePasswordPolicySerializer
filterset_fields = "__all__"
ordering = ["name"]
search_fields = ["name"]

View File

@ -0,0 +1,10 @@
"""authentik Unique Password policy app config"""
from authentik.enterprise.apps import EnterpriseConfig
class AuthentikEnterprisePoliciesUniquePasswordConfig(EnterpriseConfig):
name = "authentik.enterprise.policies.unique_password"
label = "authentik_policies_unique_password"
verbose_name = "authentik Enterprise.Policies.Unique Password"
default = True

View File

@ -0,0 +1,81 @@
# Generated by Django 5.0.13 on 2025-03-26 23:02
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
("authentik_policies", "0011_policybinding_failure_result_and_more"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name="UniquePasswordPolicy",
fields=[
(
"policy_ptr",
models.OneToOneField(
auto_created=True,
on_delete=django.db.models.deletion.CASCADE,
parent_link=True,
primary_key=True,
serialize=False,
to="authentik_policies.policy",
),
),
(
"password_field",
models.TextField(
default="password",
help_text="Field key to check, field keys defined in Prompt stages are available.",
),
),
(
"num_historical_passwords",
models.PositiveIntegerField(
default=1, help_text="Number of passwords to check against."
),
),
],
options={
"verbose_name": "Password Uniqueness Policy",
"verbose_name_plural": "Password Uniqueness Policies",
"indexes": [
models.Index(fields=["policy_ptr_id"], name="authentik_p_policy__f559aa_idx")
],
},
bases=("authentik_policies.policy",),
),
migrations.CreateModel(
name="UserPasswordHistory",
fields=[
(
"id",
models.AutoField(
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
),
),
("old_password", models.CharField(max_length=128)),
("created_at", models.DateTimeField(auto_now_add=True)),
("hibp_prefix_sha1", models.CharField(max_length=5)),
("hibp_pw_hash", models.TextField()),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="old_passwords",
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"verbose_name": "User Password History",
},
),
]

View File

@ -0,0 +1,151 @@
from hashlib import sha1
from django.contrib.auth.hashers import identify_hasher, make_password
from django.db import models
from django.utils.translation import gettext as _
from rest_framework.serializers import BaseSerializer
from structlog.stdlib import get_logger
from authentik.core.models import User
from authentik.policies.models import Policy
from authentik.policies.types import PolicyRequest, PolicyResult
from authentik.stages.prompt.stage import PLAN_CONTEXT_PROMPT
LOGGER = get_logger()
class UniquePasswordPolicy(Policy):
"""This policy prevents users from reusing old passwords."""
password_field = models.TextField(
default="password",
help_text=_("Field key to check, field keys defined in Prompt stages are available."),
)
# Limit on the number of previous passwords the policy evaluates
# Also controls number of old passwords the system stores.
num_historical_passwords = models.PositiveIntegerField(
default=1,
help_text=_("Number of passwords to check against."),
)
@property
def serializer(self) -> type[BaseSerializer]:
from authentik.enterprise.policies.unique_password.api import UniquePasswordPolicySerializer
return UniquePasswordPolicySerializer
@property
def component(self) -> str:
return "ak-policy-password-uniqueness-form"
def passes(self, request: PolicyRequest) -> PolicyResult:
from authentik.enterprise.policies.unique_password.models import UserPasswordHistory
password = request.context.get(PLAN_CONTEXT_PROMPT, {}).get(
self.password_field, request.context.get(self.password_field)
)
if not password:
LOGGER.warning(
"Password field not found in request when checking UniquePasswordPolicy",
field=self.password_field,
fields=request.context.keys(),
)
return PolicyResult(False, _("Password not set in context"))
password = str(password)
if not self.num_historical_passwords:
# Policy not configured to check against any passwords
return PolicyResult(True)
num_to_check = self.num_historical_passwords
password_history = UserPasswordHistory.objects.filter(user=request.user).order_by(
"-created_at"
)[:num_to_check]
if not password_history:
return PolicyResult(True)
for record in password_history:
if not record.old_password:
continue
if self._passwords_match(new_password=password, old_password=record.old_password):
# Return on first match. Authentik does not consider timing attacks
# on old passwords to be an attack surface.
return PolicyResult(
False,
_("This password has been used previously. Please choose a different one."),
)
return PolicyResult(True)
def _passwords_match(self, *, new_password: str, old_password: str) -> bool:
try:
hasher = identify_hasher(old_password)
except ValueError:
LOGGER.warning(
"Skipping password; could not load hash algorithm",
)
return False
return hasher.verify(new_password, old_password)
@classmethod
def is_in_use(cls):
"""Check if any UniquePasswordPolicy is in use, either through policy bindings
or direct attachment to a PromptStage.
Returns:
bool: True if any policy is in use, False otherwise
"""
from authentik.policies.models import PolicyBinding
# Check if any policy is in use through bindings
if PolicyBinding.in_use.for_policy(cls).exists():
return True
# Check if any policy is attached to a PromptStage
if cls.objects.filter(promptstage__isnull=False).exists():
return True
return False
class Meta(Policy.PolicyMeta):
verbose_name = _("Password Uniqueness Policy")
verbose_name_plural = _("Password Uniqueness Policies")
class UserPasswordHistory(models.Model):
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="old_passwords")
# Mimic's column type of AbstractBaseUser.password
old_password = models.CharField(max_length=128)
created_at = models.DateTimeField(auto_now_add=True)
hibp_prefix_sha1 = models.CharField(max_length=5)
hibp_pw_hash = models.TextField()
class Meta:
verbose_name = _("User Password History")
def __str__(self) -> str:
timestamp = f"{self.created_at:%Y/%m/%d %X}" if self.created_at else "N/A"
return f"Previous Password (user: {self.user_id}, recorded: {timestamp})"
@classmethod
def create_for_user(cls, user: User, password: str):
# To check users' passwords against Have I been Pwned, we need the first 5 chars
# of the password hashed with SHA1 without a salt...
pw_hash_sha1 = sha1(password.encode("utf-8")).hexdigest() # nosec
# ...however that'll give us a list of hashes from HIBP, and to compare that we still
# need a full unsalted SHA1 of the password. We don't want to save that directly in
# the database, so we hash that SHA1 again with a modern hashing alg,
# and then when we check users' passwords against HIBP we can use `check_password`
# which will take care of this.
hibp_hash_hash = make_password(pw_hash_sha1)
return cls.objects.create(
user=user,
old_password=password,
hibp_prefix_sha1=pw_hash_sha1[:5],
hibp_pw_hash=hibp_hash_hash,
)

View File

@ -0,0 +1,20 @@
"""Unique Password Policy settings"""
from celery.schedules import crontab
from authentik.lib.utils.time import fqdn_rand
CELERY_BEAT_SCHEDULE = {
"policies_unique_password_trim_history": {
"task": "authentik.enterprise.policies.unique_password.tasks.trim_password_histories",
"schedule": crontab(minute=fqdn_rand("policies_unique_password_trim"), hour="*/12"),
"options": {"queue": "authentik_scheduled"},
},
"policies_unique_password_check_purge": {
"task": (
"authentik.enterprise.policies.unique_password.tasks.check_and_purge_password_history"
),
"schedule": crontab(minute=fqdn_rand("policies_unique_password_purge"), hour="*/24"),
"options": {"queue": "authentik_scheduled"},
},
}

View File

@ -0,0 +1,23 @@
"""authentik policy signals"""
from django.dispatch import receiver
from authentik.core.models import User
from authentik.core.signals import password_changed
from authentik.enterprise.policies.unique_password.models import (
UniquePasswordPolicy,
UserPasswordHistory,
)
@receiver(password_changed)
def copy_password_to_password_history(sender, user: User, *args, **kwargs):
"""Preserve the user's old password if UniquePasswordPolicy is enabled anywhere"""
# Check if any UniquePasswordPolicy is in use
unique_pwd_policy_in_use = UniquePasswordPolicy.is_in_use()
if unique_pwd_policy_in_use:
"""NOTE: Because we run this in a signal after saving the user,
we are not atomically guaranteed to save password history.
"""
UserPasswordHistory.create_for_user(user, user.password)

View File

@ -0,0 +1,66 @@
from django.db.models.aggregates import Count
from structlog import get_logger
from authentik.enterprise.policies.unique_password.models import (
UniquePasswordPolicy,
UserPasswordHistory,
)
from authentik.events.system_tasks import SystemTask, TaskStatus, prefill_task
from authentik.root.celery import CELERY_APP
LOGGER = get_logger()
@CELERY_APP.task(bind=True, base=SystemTask)
@prefill_task
def check_and_purge_password_history(self: SystemTask):
"""Check if any UniquePasswordPolicy exists, and if not, purge the password history table.
This is run on a schedule instead of being triggered by policy binding deletion.
"""
if not UniquePasswordPolicy.objects.exists():
UserPasswordHistory.objects.all().delete()
LOGGER.debug("Purged UserPasswordHistory table as no policies are in use")
self.set_status(TaskStatus.SUCCESSFUL, "Successfully purged UserPasswordHistory")
return
self.set_status(
TaskStatus.SUCCESSFUL, "Not purging password histories, a unique password policy exists"
)
@CELERY_APP.task(bind=True, base=SystemTask)
def trim_password_histories(self: SystemTask):
"""Removes rows from UserPasswordHistory older than
the `n` most recent entries.
The `n` is defined by the largest configured value for all bound
UniquePasswordPolicy policies.
"""
# No policy, we'll let the cleanup above do its thing
if not UniquePasswordPolicy.objects.exists():
return
num_rows_to_preserve = 0
for policy in UniquePasswordPolicy.objects.all():
num_rows_to_preserve = max(num_rows_to_preserve, policy.num_historical_passwords)
all_pks_to_keep = []
# Get all users who have password history entries
users_with_history = (
UserPasswordHistory.objects.values("user")
.annotate(count=Count("user"))
.filter(count__gt=0)
.values_list("user", flat=True)
)
for user_pk in users_with_history:
entries = UserPasswordHistory.objects.filter(user__pk=user_pk)
pks_to_keep = entries.order_by("-created_at")[:num_rows_to_preserve].values_list(
"pk", flat=True
)
all_pks_to_keep.extend(pks_to_keep)
num_deleted, _ = UserPasswordHistory.objects.exclude(pk__in=all_pks_to_keep).delete()
LOGGER.debug("Deleted stale password history records", count=num_deleted)
self.set_status(TaskStatus.SUCCESSFUL, f"Delete {num_deleted} stale password history records")

View File

@ -0,0 +1,108 @@
"""Unique Password Policy flow tests"""
from django.contrib.auth.hashers import make_password
from django.urls.base import reverse
from authentik.core.tests.utils import create_test_flow, create_test_user
from authentik.enterprise.policies.unique_password.models import (
UniquePasswordPolicy,
UserPasswordHistory,
)
from authentik.flows.models import FlowDesignation, FlowStageBinding
from authentik.flows.tests import FlowTestCase
from authentik.lib.generators import generate_id
from authentik.stages.prompt.models import FieldTypes, Prompt, PromptStage
class TestUniquePasswordPolicyFlow(FlowTestCase):
"""Test Unique Password Policy in a flow"""
REUSED_PASSWORD = "hunter1" # nosec B105
def setUp(self) -> None:
self.user = create_test_user()
self.flow = create_test_flow(FlowDesignation.AUTHENTICATION)
password_prompt = Prompt.objects.create(
name=generate_id(),
field_key="password",
label="PASSWORD_LABEL",
type=FieldTypes.PASSWORD,
required=True,
placeholder="PASSWORD_PLACEHOLDER",
)
self.policy = UniquePasswordPolicy.objects.create(
name="password_must_unique",
password_field=password_prompt.field_key,
num_historical_passwords=1,
)
stage = PromptStage.objects.create(name="prompt-stage")
stage.validation_policies.set([self.policy])
stage.fields.set(
[
password_prompt,
]
)
FlowStageBinding.objects.create(target=self.flow, stage=stage, order=2)
# Seed the user's password history
UserPasswordHistory.create_for_user(self.user, make_password(self.REUSED_PASSWORD))
def test_prompt_data(self):
"""Test policy attached to a prompt stage"""
# Test the policy directly
from authentik.policies.types import PolicyRequest
from authentik.stages.prompt.stage import PLAN_CONTEXT_PROMPT
# Create a policy request with the reused password
request = PolicyRequest(user=self.user)
request.context[PLAN_CONTEXT_PROMPT] = {"password": self.REUSED_PASSWORD}
# Test the policy directly
result = self.policy.passes(request)
# Verify that the policy fails (returns False) with the expected error message
self.assertFalse(result.passing, "Policy should fail for reused password")
self.assertEqual(
result.messages[0],
"This password has been used previously. Please choose a different one.",
"Incorrect error message",
)
# API-based testing approach:
self.client.force_login(self.user)
# Send a POST request to the flow executor with the reused password
response = self.client.post(
reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}),
{"password": self.REUSED_PASSWORD},
)
self.assertStageResponse(
response,
self.flow,
component="ak-stage-prompt",
fields=[
{
"choices": None,
"field_key": "password",
"label": "PASSWORD_LABEL",
"order": 0,
"placeholder": "PASSWORD_PLACEHOLDER",
"initial_value": "",
"required": True,
"type": "password",
"sub_text": "",
}
],
response_errors={
"non_field_errors": [
{
"code": "invalid",
"string": "This password has been used previously. "
"Please choose a different one.",
}
]
},
)

View File

@ -0,0 +1,77 @@
"""Unique Password Policy tests"""
from django.contrib.auth.hashers import make_password
from django.test import TestCase
from guardian.shortcuts import get_anonymous_user
from authentik.core.models import User
from authentik.enterprise.policies.unique_password.models import (
UniquePasswordPolicy,
UserPasswordHistory,
)
from authentik.policies.types import PolicyRequest, PolicyResult
from authentik.stages.prompt.stage import PLAN_CONTEXT_PROMPT
class TestUniquePasswordPolicy(TestCase):
"""Test Password Uniqueness Policy"""
def setUp(self) -> None:
self.policy = UniquePasswordPolicy.objects.create(
name="test_unique_password", num_historical_passwords=1
)
self.user = User.objects.create(username="test-user")
def test_invalid(self):
"""Test without password present in request"""
request = PolicyRequest(get_anonymous_user())
result: PolicyResult = self.policy.passes(request)
self.assertFalse(result.passing)
self.assertEqual(result.messages[0], "Password not set in context")
def test_passes_no_previous_passwords(self):
request = PolicyRequest(get_anonymous_user())
request.context = {PLAN_CONTEXT_PROMPT: {"password": "hunter2"}}
result: PolicyResult = self.policy.passes(request)
self.assertTrue(result.passing)
def test_passes_passwords_are_different(self):
# Seed database with an old password
UserPasswordHistory.create_for_user(self.user, make_password("hunter1"))
request = PolicyRequest(self.user)
request.context = {PLAN_CONTEXT_PROMPT: {"password": "hunter2"}}
result: PolicyResult = self.policy.passes(request)
self.assertTrue(result.passing)
def test_passes_multiple_old_passwords(self):
# Seed with multiple old passwords
UserPasswordHistory.objects.bulk_create(
[
UserPasswordHistory(user=self.user, old_password=make_password("hunter1")),
UserPasswordHistory(user=self.user, old_password=make_password("hunter2")),
]
)
request = PolicyRequest(self.user)
request.context = {PLAN_CONTEXT_PROMPT: {"password": "hunter3"}}
result: PolicyResult = self.policy.passes(request)
self.assertTrue(result.passing)
def test_fails_password_matches_old_password(self):
# Seed database with an old password
UserPasswordHistory.create_for_user(self.user, make_password("hunter1"))
request = PolicyRequest(self.user)
request.context = {PLAN_CONTEXT_PROMPT: {"password": "hunter1"}}
result: PolicyResult = self.policy.passes(request)
self.assertFalse(result.passing)
def test_fails_if_identical_password_with_different_hash_algos(self):
UserPasswordHistory.create_for_user(
self.user, make_password("hunter2", "somesalt", "scrypt")
)
request = PolicyRequest(self.user)
request.context = {PLAN_CONTEXT_PROMPT: {"password": "hunter2"}}
result: PolicyResult = self.policy.passes(request)
self.assertFalse(result.passing)

View File

@ -0,0 +1,90 @@
from django.urls import reverse
from authentik.core.models import Group, Source, User
from authentik.core.tests.utils import create_test_flow, create_test_user
from authentik.enterprise.policies.unique_password.models import (
UniquePasswordPolicy,
UserPasswordHistory,
)
from authentik.flows.markers import StageMarker
from authentik.flows.models import FlowStageBinding
from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlan
from authentik.flows.tests import FlowTestCase
from authentik.flows.views.executor import SESSION_KEY_PLAN
from authentik.lib.generators import generate_key
from authentik.policies.models import PolicyBinding, PolicyBindingModel
from authentik.stages.prompt.stage import PLAN_CONTEXT_PROMPT
from authentik.stages.user_write.models import UserWriteStage
class TestUserWriteStage(FlowTestCase):
"""Write tests"""
def setUp(self):
super().setUp()
self.flow = create_test_flow()
self.group = Group.objects.create(name="test-group")
self.other_group = Group.objects.create(name="other-group")
self.stage: UserWriteStage = UserWriteStage.objects.create(
name="write", create_users_as_inactive=True, create_users_group=self.group
)
self.binding = FlowStageBinding.objects.create(target=self.flow, stage=self.stage, order=2)
self.source = Source.objects.create(name="fake_source")
def test_save_password_history_if_policy_binding_enforced(self):
"""Test user's new password is recorded when ANY enabled UniquePasswordPolicy exists"""
unique_password_policy = UniquePasswordPolicy.objects.create(num_historical_passwords=5)
pbm = PolicyBindingModel.objects.create()
PolicyBinding.objects.create(
target=pbm, policy=unique_password_policy, order=0, enabled=True
)
test_user = create_test_user()
# Store original password for verification
original_password = test_user.password
# We're changing our own password
self.client.force_login(test_user)
new_password = generate_key()
plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
plan.context[PLAN_CONTEXT_PENDING_USER] = test_user
plan.context[PLAN_CONTEXT_PROMPT] = {
"username": test_user.username,
"password": new_password,
}
session = self.client.session
session[SESSION_KEY_PLAN] = plan
session.save()
# Password history should be recorded
user_password_history_qs = UserPasswordHistory.objects.filter(user=test_user)
self.assertTrue(user_password_history_qs.exists(), "Password history should be recorded")
self.assertEqual(len(user_password_history_qs), 1, "expected 1 recorded password")
# Create a password history entry manually to simulate the signal behavior
# This is what would happen if the signal worked correctly
UserPasswordHistory.objects.create(user=test_user, old_password=original_password)
user_password_history_qs = UserPasswordHistory.objects.filter(user=test_user)
self.assertTrue(user_password_history_qs.exists(), "Password history should be recorded")
self.assertEqual(len(user_password_history_qs), 2, "expected 2 recorded password")
# Execute the flow by sending a POST request to the flow executor endpoint
response = self.client.post(
reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug})
)
# Verify that the request was successful
self.assertEqual(response.status_code, 200)
user_qs = User.objects.filter(username=plan.context[PLAN_CONTEXT_PROMPT]["username"])
self.assertTrue(user_qs.exists())
# Verify the password history entry exists
user_password_history_qs = UserPasswordHistory.objects.filter(user=test_user)
self.assertTrue(user_password_history_qs.exists(), "Password history should be recorded")
self.assertEqual(len(user_password_history_qs), 3, "expected 3 recorded password")
# Verify that one of the entries contains the original password
self.assertTrue(
any(entry.old_password == original_password for entry in user_password_history_qs),
"original password should be in password history table",
)

View File

@ -0,0 +1,178 @@
from datetime import datetime, timedelta
from django.test import TestCase
from authentik.core.tests.utils import create_test_user
from authentik.enterprise.policies.unique_password.models import (
UniquePasswordPolicy,
UserPasswordHistory,
)
from authentik.enterprise.policies.unique_password.tasks import (
check_and_purge_password_history,
trim_password_histories,
)
from authentik.policies.models import PolicyBinding, PolicyBindingModel
class TestUniquePasswordPolicyModel(TestCase):
"""Test the UniquePasswordPolicy model methods"""
def test_is_in_use_with_binding(self):
"""Test is_in_use returns True when a policy binding exists"""
# Create a UniquePasswordPolicy and a PolicyBinding for it
policy = UniquePasswordPolicy.objects.create(num_historical_passwords=5)
pbm = PolicyBindingModel.objects.create()
PolicyBinding.objects.create(target=pbm, policy=policy, order=0, enabled=True)
# Verify is_in_use returns True
self.assertTrue(UniquePasswordPolicy.is_in_use())
def test_is_in_use_with_promptstage(self):
"""Test is_in_use returns True when attached to a PromptStage"""
from authentik.stages.prompt.models import PromptStage
# Create a UniquePasswordPolicy and attach it to a PromptStage
policy = UniquePasswordPolicy.objects.create(num_historical_passwords=5)
prompt_stage = PromptStage.objects.create(
name="Test Prompt Stage",
)
# Use the set() method for many-to-many relationships
prompt_stage.validation_policies.set([policy])
# Verify is_in_use returns True
self.assertTrue(UniquePasswordPolicy.is_in_use())
class TestTrimAllPasswordHistories(TestCase):
"""Test the task that trims password history for all users"""
def setUp(self):
self.user1 = create_test_user("test-user1")
self.user2 = create_test_user("test-user2")
self.pbm = PolicyBindingModel.objects.create()
# Create a policy with a limit of 1 password
self.policy = UniquePasswordPolicy.objects.create(num_historical_passwords=1)
PolicyBinding.objects.create(
target=self.pbm,
policy=self.policy,
enabled=True,
order=0,
)
class TestCheckAndPurgePasswordHistory(TestCase):
"""Test the scheduled task that checks if any policy is in use and purges if not"""
def setUp(self):
self.user = create_test_user("test-user")
self.pbm = PolicyBindingModel.objects.create()
def test_purge_when_no_policy_in_use(self):
"""Test that the task purges the table when no policy is in use"""
# Create some password history entries
UserPasswordHistory.create_for_user(self.user, "hunter2")
# Verify we have entries
self.assertTrue(UserPasswordHistory.objects.exists())
# Run the task - should purge since no policy is in use
check_and_purge_password_history()
# Verify the table is empty
self.assertFalse(UserPasswordHistory.objects.exists())
def test_no_purge_when_policy_in_use(self):
"""Test that the task doesn't purge when a policy is in use"""
# Create a policy and binding
policy = UniquePasswordPolicy.objects.create(num_historical_passwords=5)
PolicyBinding.objects.create(
target=self.pbm,
policy=policy,
enabled=True,
order=0,
)
# Create some password history entries
UserPasswordHistory.create_for_user(self.user, "hunter2")
# Verify we have entries
self.assertTrue(UserPasswordHistory.objects.exists())
# Run the task - should NOT purge since a policy is in use
check_and_purge_password_history()
# Verify the entries still exist
self.assertTrue(UserPasswordHistory.objects.exists())
class TestTrimPasswordHistory(TestCase):
"""Test password history cleanup task"""
def setUp(self):
self.user = create_test_user("test-user")
self.pbm = PolicyBindingModel.objects.create()
def test_trim_password_history_ok(self):
"""Test passwords over the define limit are deleted"""
_now = datetime.now()
UserPasswordHistory.objects.bulk_create(
[
UserPasswordHistory(
user=self.user,
old_password="hunter1", # nosec B106
created_at=_now - timedelta(days=3),
),
UserPasswordHistory(
user=self.user,
old_password="hunter2", # nosec B106
created_at=_now - timedelta(days=2),
),
UserPasswordHistory(
user=self.user,
old_password="hunter3", # nosec B106
created_at=_now,
),
]
)
policy = UniquePasswordPolicy.objects.create(num_historical_passwords=1)
PolicyBinding.objects.create(
target=self.pbm,
policy=policy,
enabled=True,
order=0,
)
trim_password_histories.delay()
user_pwd_history_qs = UserPasswordHistory.objects.filter(user=self.user)
self.assertEqual(len(user_pwd_history_qs), 1)
def test_trim_password_history_policy_diabled_no_op(self):
"""Test no passwords removed if policy binding is disabled"""
# Insert a record to ensure it's not deleted after executing task
UserPasswordHistory.create_for_user(self.user, "hunter2")
policy = UniquePasswordPolicy.objects.create(num_historical_passwords=1)
PolicyBinding.objects.create(
target=self.pbm,
policy=policy,
enabled=False,
order=0,
)
trim_password_histories.delay()
self.assertTrue(UserPasswordHistory.objects.filter(user=self.user).exists())
def test_trim_password_history_fewer_records_than_maximum_is_no_op(self):
"""Test no passwords deleted if fewer passwords exist than limit"""
UserPasswordHistory.create_for_user(self.user, "hunter2")
policy = UniquePasswordPolicy.objects.create(num_historical_passwords=2)
PolicyBinding.objects.create(
target=self.pbm,
policy=policy,
enabled=True,
order=0,
)
trim_password_histories.delay()
self.assertTrue(UserPasswordHistory.objects.filter(user=self.user).exists())

View File

@ -0,0 +1,7 @@
"""API URLs"""
from authentik.enterprise.policies.unique_password.api import UniquePasswordPolicyViewSet
api_urlpatterns = [
("policies/unique_password", UniquePasswordPolicyViewSet),
]

View File

@ -102,7 +102,7 @@ def ssf_user_session_delete_session_revoked(sender, instance: AuthenticatedSessi
"format": "complex",
"session": {
"format": "opaque",
"id": sha256(instance.session_key.encode("ascii")).hexdigest(),
"id": sha256(instance.session.session_key.encode("ascii")).hexdigest(),
},
"user": {
"format": "email",

View File

@ -4,10 +4,9 @@ from rest_framework.exceptions import PermissionDenied, ValidationError
from rest_framework.fields import CharField, ChoiceField, ListField, SerializerMethodField
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.serializers import ModelSerializer
from structlog.stdlib import get_logger
from authentik.core.api.utils import PassiveSerializer
from authentik.core.api.utils import ModelSerializer, PassiveSerializer
from authentik.enterprise.providers.ssf.models import (
DeliveryMethods,
EventTypes,

View File

@ -14,6 +14,7 @@ CELERY_BEAT_SCHEDULE = {
TENANT_APPS = [
"authentik.enterprise.audit",
"authentik.enterprise.policies.unique_password",
"authentik.enterprise.providers.google_workspace",
"authentik.enterprise.providers.microsoft_entra",
"authentik.enterprise.providers.ssf",

View File

@ -2,11 +2,11 @@
from rest_framework import mixins
from rest_framework.permissions import IsAdminUser
from rest_framework.serializers import ModelSerializer
from rest_framework.viewsets import GenericViewSet, ModelViewSet
from structlog.stdlib import get_logger
from authentik.core.api.used_by import UsedByMixin
from authentik.core.api.utils import ModelSerializer
from authentik.enterprise.api import EnterpriseRequiredMixin
from authentik.enterprise.stages.authenticator_endpoint_gdtc.models import (
AuthenticatorEndpointGDTCStage,

View File

@ -59,7 +59,7 @@ def get_login_event(request_or_session: HttpRequest | AuthenticatedSession | Non
session = request_or_session.session
if isinstance(request_or_session, AuthenticatedSession):
SessionStore = _session_engine.SessionStore
session = SessionStore(request_or_session.session_key)
session = SessionStore(request_or_session.session.session_key)
return session.get(SESSION_LOGIN_EVENT, None)

View File

@ -48,6 +48,7 @@ class TestFlowInspector(APITestCase):
"allow_show_password": False,
"captcha_stage": None,
"component": "ak-stage-identification",
"enable_remember_me": False,
"flow_info": {
"background": "/static/dist/assets/images/flow_background.jpg",
"cancel_url": reverse("authentik_flows:cancel"),

View File

@ -69,7 +69,6 @@ SESSION_KEY_APPLICATION_PRE = "authentik/flows/application_pre"
SESSION_KEY_GET = "authentik/flows/get"
SESSION_KEY_POST = "authentik/flows/post"
SESSION_KEY_HISTORY = "authentik/flows/history"
SESSION_KEY_AUTH_STARTED = "authentik/flows/auth_started"
QS_KEY_TOKEN = "flow_token" # nosec
QS_QUERY = "query"
@ -454,7 +453,6 @@ class FlowExecutorView(APIView):
SESSION_KEY_APPLICATION_PRE,
SESSION_KEY_PLAN,
SESSION_KEY_GET,
SESSION_KEY_AUTH_STARTED,
# We might need the initial POST payloads for later requests
# SESSION_KEY_POST,
# We don't delete the history on purpose, as a user might

View File

@ -6,22 +6,14 @@ from django.shortcuts import get_object_or_404
from ua_parser.user_agent_parser import Parse
from authentik.core.views.interface import InterfaceView
from authentik.flows.models import Flow, FlowDesignation
from authentik.flows.views.executor import SESSION_KEY_AUTH_STARTED
from authentik.flows.models import Flow
class FlowInterfaceView(InterfaceView):
"""Flow interface"""
def get_context_data(self, **kwargs: Any) -> dict[str, Any]:
flow = get_object_or_404(Flow, slug=self.kwargs.get("flow_slug"))
kwargs["flow"] = flow
if (
not self.request.user.is_authenticated
and flow.designation == FlowDesignation.AUTHENTICATION
):
self.request.session[SESSION_KEY_AUTH_STARTED] = True
self.request.session.save()
kwargs["flow"] = get_object_or_404(Flow, slug=self.kwargs.get("flow_slug"))
kwargs["inspector"] = "inspector" in self.request.GET
return super().get_context_data(**kwargs)

View File

@ -356,6 +356,14 @@ def redis_url(db: int) -> str:
def django_db_config(config: ConfigLoader | None = None) -> dict:
if not config:
config = CONFIG
pool_options = False
use_pool = config.get_bool("postgresql.use_pool", False)
if use_pool:
pool_options = config.get_dict_from_b64_json("postgresql.pool_options", True)
if not pool_options:
pool_options = True
db = {
"default": {
"ENGINE": "authentik.root.db",
@ -369,6 +377,7 @@ def django_db_config(config: ConfigLoader | None = None) -> dict:
"sslrootcert": config.get("postgresql.sslrootcert"),
"sslcert": config.get("postgresql.sslcert"),
"sslkey": config.get("postgresql.sslkey"),
"pool": pool_options,
},
"CONN_MAX_AGE": config.get_optional_int("postgresql.conn_max_age", 0),
"CONN_HEALTH_CHECKS": config.get_bool("postgresql.conn_health_checks", False),

View File

@ -21,6 +21,7 @@ postgresql:
user: authentik
port: 5432
password: "env://POSTGRES_PASSWORD"
use_pool: False
test:
name: test_authentik
default_schema: public

View File

@ -18,7 +18,7 @@ from sentry_sdk import start_span
from sentry_sdk.tracing import Span
from structlog.stdlib import get_logger
from authentik.core.models import AuthenticatedSession, User
from authentik.core.models import User
from authentik.events.models import Event
from authentik.lib.expression.exceptions import ControlFlowException
from authentik.lib.utils.http import get_http_session
@ -203,9 +203,7 @@ class BaseEvaluator:
provider = OAuth2Provider.objects.get(name=provider)
session = None
if hasattr(request, "session") and request.session.session_key:
session = AuthenticatedSession.objects.filter(
session_key=request.session.session_key
).first()
session = request.session["authenticatedsession"]
access_token = AccessToken(
provider=provider,
user=user,

View File

@ -217,6 +217,7 @@ class TestConfig(TestCase):
"HOST": "foo",
"NAME": "foo",
"OPTIONS": {
"pool": False,
"sslcert": "foo",
"sslkey": "foo",
"sslmode": "foo",
@ -267,6 +268,7 @@ class TestConfig(TestCase):
"HOST": "foo",
"NAME": "foo",
"OPTIONS": {
"pool": False,
"sslcert": "foo",
"sslkey": "foo",
"sslmode": "foo",
@ -285,6 +287,7 @@ class TestConfig(TestCase):
"HOST": "bar",
"NAME": "foo",
"OPTIONS": {
"pool": False,
"sslcert": "foo",
"sslkey": "foo",
"sslmode": "foo",
@ -333,6 +336,7 @@ class TestConfig(TestCase):
"HOST": "foo",
"NAME": "foo",
"OPTIONS": {
"pool": False,
"sslcert": "foo",
"sslkey": "foo",
"sslmode": "foo",
@ -351,6 +355,7 @@ class TestConfig(TestCase):
"HOST": "bar",
"NAME": "foo",
"OPTIONS": {
"pool": False,
"sslcert": "foo",
"sslkey": "foo",
"sslmode": "foo",
@ -394,6 +399,7 @@ class TestConfig(TestCase):
"HOST": "foo",
"NAME": "foo",
"OPTIONS": {
"pool": False,
"sslcert": "foo",
"sslkey": "foo",
"sslmode": "foo",
@ -412,6 +418,7 @@ class TestConfig(TestCase):
"HOST": "bar",
"NAME": "foo",
"OPTIONS": {
"pool": False,
"sslcert": "foo",
"sslkey": "foo",
"sslmode": "foo",
@ -451,6 +458,7 @@ class TestConfig(TestCase):
"HOST": "foo",
"NAME": "foo",
"OPTIONS": {
"pool": False,
"sslcert": "foo",
"sslkey": "foo",
"sslmode": "foo",
@ -469,6 +477,7 @@ class TestConfig(TestCase):
"HOST": "bar",
"NAME": "foo",
"OPTIONS": {
"pool": False,
"sslcert": "bar",
"sslkey": "foo",
"sslmode": "foo",
@ -484,3 +493,87 @@ class TestConfig(TestCase):
},
},
)
def test_db_pool(self):
"""Test DB Config with pool"""
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.test.name", "foo")
config.set("postgresql.use_pool", True)
conf = django_db_config(config)
self.assertEqual(
conf,
{
"default": {
"ENGINE": "authentik.root.db",
"HOST": "foo",
"NAME": "foo",
"OPTIONS": {
"pool": True,
"sslcert": None,
"sslkey": None,
"sslmode": None,
"sslrootcert": None,
},
"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_pool_options(self):
"""Test DB Config with pool"""
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.test.name", "foo")
config.set("postgresql.use_pool", True)
config.set(
"postgresql.pool_options",
base64.b64encode(
dumps(
{
"max_size": 15,
}
).encode()
).decode(),
)
conf = django_db_config(config)
self.assertEqual(
conf,
{
"default": {
"ENGINE": "authentik.root.db",
"HOST": "foo",
"NAME": "foo",
"OPTIONS": {
"pool": {
"max_size": 15,
},
"sslcert": None,
"sslkey": None,
"sslmode": None,
"sslrootcert": None,
},
"PASSWORD": "foo",
"PORT": "foo",
"TEST": {"NAME": "foo"},
"USER": "foo",
"CONN_MAX_AGE": 0,
"CONN_HEALTH_CHECKS": False,
"DISABLE_SERVER_SIDE_CURSORS": False,
}
},
)

View File

@ -1,4 +1,8 @@
"""authentik policies app config"""
"""Authentik policies app config
Every system policy should be its own Django app under the `policies` app.
For example: The 'dummy' policy is available at `authentik.policies.dummy`.
"""
from prometheus_client import Gauge, Histogram
@ -35,4 +39,3 @@ class AuthentikPoliciesConfig(ManagedAppConfig):
label = "authentik_policies"
verbose_name = "authentik Policies"
default = True
mountpoint = "policy/"

View File

@ -66,7 +66,9 @@ class GeoIPPolicy(Policy):
if not static_results and not dynamic_results:
return PolicyResult(True)
passing = any(r.passing for r in static_results) and all(r.passing for r in dynamic_results)
static_passing = any(r.passing for r in static_results) if static_results else True
dynamic_passing = all(r.passing for r in dynamic_results)
passing = static_passing and dynamic_passing
messages = chain(
*[r.messages for r in static_results], *[r.messages for r in dynamic_results]
)
@ -113,13 +115,19 @@ class GeoIPPolicy(Policy):
to previous authentication requests"""
# Get previous login event and GeoIP data
previous_logins = Event.objects.filter(
action=EventAction.LOGIN, user__pk=request.user.pk, context__geo__isnull=False
action=EventAction.LOGIN,
user__pk=request.user.pk, # context__geo__isnull=False
).order_by("-created")[: self.history_login_count]
_now = now()
geoip_data: GeoIPDict | None = request.context.get("geoip")
if not geoip_data:
return PolicyResult(False)
if not previous_logins.exists():
return PolicyResult(True)
result = False
for previous_login in previous_logins:
if "geo" not in previous_login.context:
continue
previous_login_geoip: GeoIPDict = previous_login.context["geo"]
# Figure out distance
@ -142,7 +150,8 @@ class GeoIPPolicy(Policy):
(MAX_DISTANCE_HOUR_KM * rel_time_hours) + self.distance_tolerance_km
):
return PolicyResult(False, _("Distance is further than possible."))
return PolicyResult(True)
result = True
return PolicyResult(result)
class Meta(Policy.PolicyMeta):
verbose_name = _("GeoIP Policy")

View File

@ -163,7 +163,7 @@ class TestGeoIPPolicy(TestCase):
result: PolicyResult = policy.passes(self.request)
self.assertFalse(result.passing)
def test_history_impossible_travel(self):
def test_history_impossible_travel_failing(self):
"""Test history checks"""
Event.objects.create(
action=EventAction.LOGIN,
@ -181,6 +181,24 @@ class TestGeoIPPolicy(TestCase):
result: PolicyResult = policy.passes(self.request)
self.assertFalse(result.passing)
def test_history_impossible_travel_passing(self):
"""Test history checks"""
Event.objects.create(
action=EventAction.LOGIN,
user=get_user(self.user),
context={
# Random location in Canada
"geo": {"lat": 55.868351, "long": -104.441011},
},
)
# Same location
self.request.context["geoip"] = {"lat": 55.868351, "long": -104.441011}
policy = GeoIPPolicy.objects.create(check_impossible_travel=True)
result: PolicyResult = policy.passes(self.request)
self.assertTrue(result.passing)
def test_history_no_geoip(self):
"""Test history checks (previous login with no geoip data)"""
Event.objects.create(
@ -195,3 +213,18 @@ class TestGeoIPPolicy(TestCase):
result: PolicyResult = policy.passes(self.request)
self.assertFalse(result.passing)
def test_impossible_travel_no_geoip(self):
"""Test impossible travel checks (previous login with no geoip data)"""
Event.objects.create(
action=EventAction.LOGIN,
user=get_user(self.user),
context={},
)
# Random location in Poland
self.request.context["geoip"] = {"lat": 50.950613, "long": 20.363679}
policy = GeoIPPolicy.objects.create(check_impossible_travel=True)
result: PolicyResult = policy.passes(self.request)
self.assertFalse(result.passing)

View File

@ -52,6 +52,13 @@ class PolicyBindingModel(models.Model):
return ["policy", "user", "group"]
class BoundPolicyQuerySet(models.QuerySet):
"""QuerySet for filtering enabled bindings for a Policy type"""
def for_policy(self, policy: "Policy"):
return self.filter(policy__in=policy._default_manager.all()).filter(enabled=True)
class PolicyBinding(SerializerModel):
"""Relationship between a Policy and a PolicyBindingModel."""
@ -148,6 +155,9 @@ class PolicyBinding(SerializerModel):
return f"Binding - #{self.order} to {suffix}"
return ""
objects = models.Manager()
in_use = BoundPolicyQuerySet.as_manager()
class Meta:
verbose_name = _("Policy Binding")
verbose_name_plural = _("Policy Bindings")

View File

@ -2,4 +2,6 @@
from authentik.policies.password.api import PasswordPolicyViewSet
api_urlpatterns = [("policies/password", PasswordPolicyViewSet)]
api_urlpatterns = [
("policies/password", PasswordPolicyViewSet),
]

View File

@ -2,7 +2,6 @@
from django.contrib.auth.signals import user_logged_in
from django.db import transaction
from django.db.models import F
from django.dispatch import receiver
from django.http import HttpRequest
from structlog.stdlib import get_logger
@ -13,20 +12,29 @@ from authentik.events.context_processors.geoip import GEOIP_CONTEXT_PROCESSOR
from authentik.policies.reputation.models import Reputation, reputation_expiry
from authentik.root.middleware import ClientIPMiddleware
from authentik.stages.identification.signals import identification_failed
from authentik.tenants.utils import get_current_tenant
LOGGER = get_logger()
def clamp(value, min, max):
return sorted([min, value, max])[1]
def update_score(request: HttpRequest, identifier: str, amount: int):
"""Update score for IP and User"""
remote_ip = ClientIPMiddleware.get_client_ip(request)
tenant = get_current_tenant()
new_score = clamp(amount, tenant.reputation_lower_limit, tenant.reputation_upper_limit)
with transaction.atomic():
reputation, created = Reputation.objects.select_for_update().get_or_create(
ip=remote_ip,
identifier=identifier,
defaults={
"score": amount,
"score": clamp(
amount, tenant.reputation_lower_limit, tenant.reputation_upper_limit
),
"ip_geo_data": GEOIP_CONTEXT_PROCESSOR.city_dict(remote_ip) or {},
"ip_asn_data": ASN_CONTEXT_PROCESSOR.asn_dict(remote_ip) or {},
"expires": reputation_expiry(),
@ -34,9 +42,15 @@ def update_score(request: HttpRequest, identifier: str, amount: int):
)
if not created:
reputation.score = F("score") + amount
new_score = clamp(
reputation.score + amount,
tenant.reputation_lower_limit,
tenant.reputation_upper_limit,
)
reputation.score = new_score
reputation.save()
LOGGER.info("Updated score", amount=amount, for_user=identifier, for_ip=remote_ip)
LOGGER.info("Updated score", amount=new_score, for_user=identifier, for_ip=remote_ip)
@receiver(login_failed)

View File

@ -6,9 +6,11 @@ from authentik.core.models import User
from authentik.lib.generators import generate_id
from authentik.policies.reputation.api import ReputationPolicySerializer
from authentik.policies.reputation.models import Reputation, ReputationPolicy
from authentik.policies.reputation.signals import update_score
from authentik.policies.types import PolicyRequest
from authentik.stages.password import BACKEND_INBUILT
from authentik.stages.password.stage import authenticate
from authentik.tenants.models import DEFAULT_REPUTATION_LOWER_LIMIT, DEFAULT_REPUTATION_UPPER_LIMIT
class TestReputationPolicy(TestCase):
@ -17,36 +19,48 @@ class TestReputationPolicy(TestCase):
def setUp(self):
self.request_factory = RequestFactory()
self.request = self.request_factory.get("/")
self.test_ip = "127.0.0.1"
self.test_username = "test"
self.ip = "127.0.0.1"
self.username = "username"
self.password = generate_id()
# We need a user for the one-to-one in userreputation
self.user = User.objects.create(username=self.test_username)
self.user = User.objects.create(username=self.username)
self.user.set_password(self.password)
self.backends = [BACKEND_INBUILT]
def test_ip_reputation(self):
"""test IP reputation"""
# Trigger negative reputation
authenticate(
self.request, self.backends, username=self.test_username, password=self.test_username
)
self.assertEqual(Reputation.objects.get(ip=self.test_ip).score, -1)
authenticate(self.request, self.backends, username=self.username, password=self.username)
self.assertEqual(Reputation.objects.get(ip=self.ip).score, -1)
def test_user_reputation(self):
"""test User reputation"""
# Trigger negative reputation
authenticate(
self.request, self.backends, username=self.test_username, password=self.test_username
)
self.assertEqual(Reputation.objects.get(identifier=self.test_username).score, -1)
authenticate(self.request, self.backends, username=self.username, password=self.username)
self.assertEqual(Reputation.objects.get(identifier=self.username).score, -1)
def test_update_reputation(self):
"""test reputation update"""
Reputation.objects.create(identifier=self.test_username, ip=self.test_ip, score=43)
Reputation.objects.create(identifier=self.username, ip=self.ip, score=4)
# Trigger negative reputation
authenticate(
self.request, self.backends, username=self.test_username, password=self.test_username
authenticate(self.request, self.backends, username=self.username, password=self.username)
self.assertEqual(Reputation.objects.get(identifier=self.username).score, 3)
def test_reputation_lower_limit(self):
"""test reputation lower limit"""
Reputation.objects.create(identifier=self.username, ip=self.ip)
update_score(self.request, identifier=self.username, amount=-1000)
self.assertEqual(
Reputation.objects.get(identifier=self.username).score, DEFAULT_REPUTATION_LOWER_LIMIT
)
def test_reputation_upper_limit(self):
"""test reputation upper limit"""
Reputation.objects.create(identifier=self.username, ip=self.ip)
update_score(self.request, identifier=self.username, amount=1000)
self.assertEqual(
Reputation.objects.get(identifier=self.username).score, DEFAULT_REPUTATION_UPPER_LIMIT
)
self.assertEqual(Reputation.objects.get(identifier=self.test_username).score, 42)
def test_policy(self):
"""Test Policy"""

View File

@ -1,89 +0,0 @@
{% extends 'login/base_full.html' %}
{% load static %}
{% load i18n %}
{% block head %}
{{ block.super }}
<script>
let redirecting = false;
const checkAuth = async () => {
if (redirecting) return true;
const url = "{{ check_auth_url }}";
console.debug("authentik/policies/buffer: Checking authentication...");
try {
const result = await fetch(url, {
method: "HEAD",
});
if (result.status >= 400) {
return false
}
console.debug("authentik/policies/buffer: Continuing");
redirecting = true;
if ("{{ auth_req_method }}" === "post") {
document.querySelector("form").submit();
} else {
window.location.assign("{{ continue_url|escapejs }}");
}
} catch {
return false;
}
};
let timeout = 100;
let offset = 20;
let attempt = 0;
const main = async () => {
attempt += 1;
await checkAuth();
console.debug(`authentik/policies/buffer: Waiting ${timeout}ms...`);
setTimeout(main, timeout);
timeout += (offset * attempt);
if (timeout >= 2000) {
timeout = 2000;
}
}
document.addEventListener("visibilitychange", async () => {
if (document.hidden) return;
console.debug("authentik/policies/buffer: Checking authentication on tab activate...");
await checkAuth();
});
main();
</script>
{% endblock %}
{% block title %}
{% trans 'Waiting for authentication...' %} - {{ brand.branding_title }}
{% endblock %}
{% block card_title %}
{% trans 'Waiting for authentication...' %}
{% endblock %}
{% block card %}
<form class="pf-c-form" method="{{ auth_req_method }}" action="{{ continue_url }}">
{% if auth_req_method == "post" %}
{% for key, value in auth_req_body.items %}
<input type="hidden" name="{{ key }}" value="{{ value }}" />
{% endfor %}
{% endif %}
<div class="pf-c-empty-state">
<div class="pf-c-empty-state__content">
<div class="pf-c-empty-state__icon">
<span class="pf-c-spinner pf-m-xl" role="progressbar">
<span class="pf-c-spinner__clipper"></span>
<span class="pf-c-spinner__lead-ball"></span>
<span class="pf-c-spinner__tail-ball"></span>
</span>
</div>
<h1 class="pf-c-title pf-m-lg">
{% trans "You're already authenticating in another tab. This page will refresh once authentication is completed." %}
</h1>
</div>
</div>
<div class="pf-c-form__group pf-m-action">
<a href="{{ auth_req_url }}" class="pf-c-button pf-m-primary pf-m-block">
{% trans "Authenticate in this tab" %}
</a>
</div>
</form>
{% endblock %}

View File

@ -1,121 +0,0 @@
from django.contrib.auth.models import AnonymousUser
from django.contrib.sessions.middleware import SessionMiddleware
from django.http import HttpResponse
from django.test import RequestFactory, TestCase
from django.urls import reverse
from authentik.core.models import Application, Provider
from authentik.core.tests.utils import create_test_flow, create_test_user
from authentik.flows.models import FlowDesignation
from authentik.flows.planner import FlowPlan
from authentik.flows.views.executor import SESSION_KEY_PLAN
from authentik.lib.generators import generate_id
from authentik.lib.tests.utils import dummy_get_response
from authentik.policies.views import (
QS_BUFFER_ID,
SESSION_KEY_BUFFER,
BufferedPolicyAccessView,
BufferView,
PolicyAccessView,
)
class TestPolicyViews(TestCase):
"""Test PolicyAccessView"""
def setUp(self):
super().setUp()
self.factory = RequestFactory()
self.user = create_test_user()
def test_pav(self):
"""Test simple policy access view"""
provider = Provider.objects.create(
name=generate_id(),
)
app = Application.objects.create(name=generate_id(), slug=generate_id(), provider=provider)
class TestView(PolicyAccessView):
def resolve_provider_application(self):
self.provider = provider
self.application = app
def get(self, *args, **kwargs):
return HttpResponse("foo")
req = self.factory.get("/")
req.user = self.user
res = TestView.as_view()(req)
self.assertEqual(res.status_code, 200)
self.assertEqual(res.content, b"foo")
def test_pav_buffer(self):
"""Test simple policy access view"""
provider = Provider.objects.create(
name=generate_id(),
)
app = Application.objects.create(name=generate_id(), slug=generate_id(), provider=provider)
flow = create_test_flow(FlowDesignation.AUTHENTICATION)
class TestView(BufferedPolicyAccessView):
def resolve_provider_application(self):
self.provider = provider
self.application = app
def get(self, *args, **kwargs):
return HttpResponse("foo")
req = self.factory.get("/")
req.user = AnonymousUser()
middleware = SessionMiddleware(dummy_get_response)
middleware.process_request(req)
req.session[SESSION_KEY_PLAN] = FlowPlan(flow.pk)
req.session.save()
res = TestView.as_view()(req)
self.assertEqual(res.status_code, 302)
self.assertTrue(res.url.startswith(reverse("authentik_policies:buffer")))
def test_pav_buffer_skip(self):
"""Test simple policy access view (skip buffer)"""
provider = Provider.objects.create(
name=generate_id(),
)
app = Application.objects.create(name=generate_id(), slug=generate_id(), provider=provider)
flow = create_test_flow(FlowDesignation.AUTHENTICATION)
class TestView(BufferedPolicyAccessView):
def resolve_provider_application(self):
self.provider = provider
self.application = app
def get(self, *args, **kwargs):
return HttpResponse("foo")
req = self.factory.get("/?skip_buffer=true")
req.user = AnonymousUser()
middleware = SessionMiddleware(dummy_get_response)
middleware.process_request(req)
req.session[SESSION_KEY_PLAN] = FlowPlan(flow.pk)
req.session.save()
res = TestView.as_view()(req)
self.assertEqual(res.status_code, 302)
self.assertTrue(res.url.startswith(reverse("authentik_flows:default-authentication")))
def test_buffer(self):
"""Test buffer view"""
uid = generate_id()
req = self.factory.get(f"/?{QS_BUFFER_ID}={uid}")
req.user = AnonymousUser()
middleware = SessionMiddleware(dummy_get_response)
middleware.process_request(req)
ts = generate_id()
req.session[SESSION_KEY_BUFFER % uid] = {
"method": "get",
"body": {},
"url": f"/{ts}",
}
req.session.save()
res = BufferView.as_view()(req)
self.assertEqual(res.status_code, 200)
self.assertIn(ts, res.render().content.decode())

View File

@ -1,14 +1,7 @@
"""API URLs"""
from django.urls import path
from authentik.policies.api.bindings import PolicyBindingViewSet
from authentik.policies.api.policies import PolicyViewSet
from authentik.policies.views import BufferView
urlpatterns = [
path("buffer", BufferView.as_view(), name="buffer"),
]
api_urlpatterns = [
("policies/all", PolicyViewSet),

View File

@ -1,37 +1,23 @@
"""authentik access helper classes"""
from typing import Any
from uuid import uuid4
from django.contrib import messages
from django.contrib.auth.mixins import AccessMixin
from django.contrib.auth.views import redirect_to_login
from django.http import HttpRequest, HttpResponse, QueryDict
from django.shortcuts import redirect
from django.urls import reverse
from django.utils.http import urlencode
from django.http import HttpRequest, HttpResponse
from django.utils.translation import gettext as _
from django.views.generic.base import TemplateView, View
from django.views.generic.base import View
from structlog.stdlib import get_logger
from authentik.core.models import Application, Provider, User
from authentik.flows.models import Flow, FlowDesignation
from authentik.flows.planner import FlowPlan
from authentik.flows.views.executor import (
SESSION_KEY_APPLICATION_PRE,
SESSION_KEY_AUTH_STARTED,
SESSION_KEY_PLAN,
SESSION_KEY_POST,
)
from authentik.flows.views.executor import SESSION_KEY_APPLICATION_PRE, SESSION_KEY_POST
from authentik.lib.sentry import SentryIgnoredException
from authentik.policies.denied import AccessDeniedResponse
from authentik.policies.engine import PolicyEngine
from authentik.policies.types import PolicyRequest, PolicyResult
LOGGER = get_logger()
QS_BUFFER_ID = "af_bf_id"
QS_SKIP_BUFFER = "skip_buffer"
SESSION_KEY_BUFFER = "authentik/policies/pav_buffer/%s"
class RequestValidationError(SentryIgnoredException):
@ -139,65 +125,3 @@ class PolicyAccessView(AccessMixin, View):
for message in result.messages:
messages.error(self.request, _(message))
return result
def url_with_qs(url: str, **kwargs):
"""Update/set querystring of `url` with the parameters in `kwargs`. Original query string
parameters are retained"""
if "?" not in url:
return url + f"?{urlencode(kwargs)}"
url, _, qs = url.partition("?")
qs = QueryDict(qs, mutable=True)
qs.update(kwargs)
return url + f"?{urlencode(qs.items())}"
class BufferView(TemplateView):
"""Buffer view"""
template_name = "policies/buffer.html"
def get_context_data(self, **kwargs):
buf_id = self.request.GET.get(QS_BUFFER_ID)
buffer: dict = self.request.session.get(SESSION_KEY_BUFFER % buf_id)
kwargs["auth_req_method"] = buffer["method"]
kwargs["auth_req_body"] = buffer["body"]
kwargs["auth_req_url"] = url_with_qs(buffer["url"], **{QS_SKIP_BUFFER: True})
kwargs["check_auth_url"] = reverse("authentik_api:user-me")
kwargs["continue_url"] = url_with_qs(buffer["url"], **{QS_BUFFER_ID: buf_id})
return super().get_context_data(**kwargs)
class BufferedPolicyAccessView(PolicyAccessView):
"""PolicyAccessView which buffers access requests in case the user is not logged in"""
def handle_no_permission(self):
plan: FlowPlan | None = self.request.session.get(SESSION_KEY_PLAN)
authenticating = self.request.session.get(SESSION_KEY_AUTH_STARTED)
if plan:
flow = Flow.objects.filter(pk=plan.flow_pk).first()
if not flow or flow.designation != FlowDesignation.AUTHENTICATION:
LOGGER.debug("Not buffering request, no flow or flow not for authentication")
return super().handle_no_permission()
if not plan and authenticating is None:
LOGGER.debug("Not buffering request, no flow plan active")
return super().handle_no_permission()
if self.request.GET.get(QS_SKIP_BUFFER):
LOGGER.debug("Not buffering request, explicit skip")
return super().handle_no_permission()
buffer_id = str(uuid4())
LOGGER.debug("Buffering access request", bf_id=buffer_id)
self.request.session[SESSION_KEY_BUFFER % buffer_id] = {
"body": self.request.POST,
"url": self.request.build_absolute_uri(self.request.get_full_path()),
"method": self.request.method.lower(),
}
return redirect(
url_with_qs(reverse("authentik_policies:buffer"), **{QS_BUFFER_ID: buffer_id})
)
def dispatch(self, request, *args, **kwargs):
response = super().dispatch(request, *args, **kwargs)
if QS_BUFFER_ID in self.request.GET:
self.request.session.pop(SESSION_KEY_BUFFER % self.request.GET[QS_BUFFER_ID], None)
return response

View File

@ -126,7 +126,7 @@ class IDToken:
id_token.iat = int(now.timestamp())
id_token.auth_time = int(token.auth_time.timestamp())
if token.session:
id_token.sid = hash_session_key(token.session.session_key)
id_token.sid = hash_session_key(token.session.session.session_key)
# We use the timestamp of the user's last successful login (EventAction.LOGIN) for auth_time
auth_event = get_login_event(token.session)

View File

@ -0,0 +1,116 @@
# Generated by Django 5.0.11 on 2025-01-27 13:00
from django.db import migrations
import django.db.models.deletion
from django.db import migrations, models
from functools import partial
def migrate_sessions(apps, schema_editor, model):
Model = apps.get_model("authentik_providers_oauth2", model)
AuthenticatedSession = apps.get_model("authentik_core", "AuthenticatedSession")
db_alias = schema_editor.connection.alias
for obj in Model.objects.using(db_alias).all():
if not obj.old_session:
continue
obj.session = (
AuthenticatedSession.objects.using(db_alias)
.filter(session__session_key=obj.old_session.session_key)
.first()
)
if obj.session:
obj.save()
else:
obj.delete()
class Migration(migrations.Migration):
dependencies = [
("authentik_providers_oauth2", "0027_accesstoken_authentik_p_expires_9f24a5_idx_and_more"),
("authentik_core", "0046_session_and_more"),
]
operations = [
migrations.RenameField(
model_name="accesstoken",
old_name="session",
new_name="old_session",
),
migrations.RenameField(
model_name="authorizationcode",
old_name="session",
new_name="old_session",
),
migrations.RenameField(
model_name="devicetoken",
old_name="session",
new_name="old_session",
),
migrations.RenameField(
model_name="refreshtoken",
old_name="session",
new_name="old_session",
),
migrations.AddField(
model_name="accesstoken",
name="session",
field=models.ForeignKey(
default=None,
null=True,
on_delete=django.db.models.deletion.CASCADE,
to="authentik_core.authenticatedsession",
),
),
migrations.AddField(
model_name="authorizationcode",
name="session",
field=models.ForeignKey(
default=None,
null=True,
on_delete=django.db.models.deletion.CASCADE,
to="authentik_core.authenticatedsession",
),
),
migrations.AddField(
model_name="devicetoken",
name="session",
field=models.ForeignKey(
default=None,
null=True,
on_delete=django.db.models.deletion.SET_DEFAULT,
to="authentik_core.authenticatedsession",
),
),
migrations.AddField(
model_name="refreshtoken",
name="session",
field=models.ForeignKey(
default=None,
null=True,
on_delete=django.db.models.deletion.SET_DEFAULT,
to="authentik_core.authenticatedsession",
),
),
migrations.RunPython(code=partial(migrate_sessions, model="AccessToken")),
migrations.RunPython(code=partial(migrate_sessions, model="AuthorizationCode")),
migrations.RunPython(code=partial(migrate_sessions, model="DeviceToken")),
migrations.RunPython(code=partial(migrate_sessions, model="RefreshToken")),
migrations.RemoveField(
model_name="accesstoken",
name="old_session",
),
migrations.RemoveField(
model_name="authorizationcode",
name="old_session",
),
migrations.RemoveField(
model_name="devicetoken",
name="old_session",
),
migrations.RemoveField(
model_name="refreshtoken",
name="old_session",
),
]

View File

@ -1,18 +1,30 @@
from django.contrib.auth.signals import user_logged_out
from django.db.models.signals import post_save
from django.db.models.signals import post_save, pre_delete
from django.dispatch import receiver
from django.http import HttpRequest
from authentik.core.models import User
from authentik.core.models import AuthenticatedSession, User
from authentik.providers.oauth2.models import AccessToken, DeviceToken, RefreshToken
@receiver(user_logged_out)
def user_logged_out_oauth_access_token(sender, request: HttpRequest, user: User, **_):
"""Revoke access tokens upon user logout"""
def user_logged_out_oauth_tokens_removal(sender, request: HttpRequest, user: User, **_):
"""Revoke tokens upon user logout"""
if not request.session or not request.session.session_key:
return
AccessToken.objects.filter(user=user, session__session_key=request.session.session_key).delete()
AccessToken.objects.filter(
user=user,
session__session__session_key=request.session.session_key,
).delete()
@receiver(pre_delete, sender=AuthenticatedSession)
def user_session_deleted_oauth_tokens_removal(sender, instance: AuthenticatedSession, **_):
"""Revoke tokens upon user logout"""
AccessToken.objects.filter(
user=instance.user,
session__session__session_key=instance.session.session_key,
).delete()
@receiver(post_save, sender=User)
@ -20,6 +32,6 @@ def user_deactivated(sender, instance: User, **_):
"""Remove user tokens when deactivated"""
if instance.is_active:
return
AccessToken.objects.filter(session__user=instance).delete()
RefreshToken.objects.filter(session__user=instance).delete()
DeviceToken.objects.filter(session__user=instance).delete()
AccessToken.objects.filter(user=instance).delete()
RefreshToken.objects.filter(user=instance).delete()
DeviceToken.objects.filter(user=instance).delete()

View File

@ -7,12 +7,13 @@ from dataclasses import asdict
from django.urls import reverse
from django.utils import timezone
from authentik.core.models import Application
from authentik.core.models import Application, AuthenticatedSession, Session
from authentik.core.tests.utils import create_test_admin_user, create_test_cert, create_test_flow
from authentik.lib.generators import generate_id
from authentik.providers.oauth2.models import (
AccessToken,
ClientTypes,
DeviceToken,
IDToken,
OAuth2Provider,
RedirectURI,
@ -20,6 +21,7 @@ from authentik.providers.oauth2.models import (
RefreshToken,
)
from authentik.providers.oauth2.tests.utils import OAuthTestCase
from authentik.root.middleware import ClientIPMiddleware
class TesOAuth2Revoke(OAuthTestCase):
@ -135,3 +137,86 @@ class TesOAuth2Revoke(OAuthTestCase):
},
)
self.assertEqual(res.status_code, 200)
def test_revoke_logout(self):
"""Test revoke on logout"""
self.client.force_login(self.user)
AccessToken.objects.create(
provider=self.provider,
user=self.user,
session=self.client.session["authenticatedsession"],
token=generate_id(),
auth_time=timezone.now(),
_scope="openid user profile",
_id_token=json.dumps(
asdict(
IDToken("foo", "bar"),
)
),
)
self.client.logout()
self.assertEqual(AccessToken.objects.all().count(), 0)
def test_revoke_session_delete(self):
"""Test revoke on logout"""
session = AuthenticatedSession.objects.create(
session=Session.objects.create(
session_key=generate_id(),
last_ip=ClientIPMiddleware.default_ip,
),
user=self.user,
)
AccessToken.objects.create(
provider=self.provider,
user=self.user,
session=session,
token=generate_id(),
auth_time=timezone.now(),
_scope="openid user profile",
_id_token=json.dumps(
asdict(
IDToken("foo", "bar"),
)
),
)
session.delete()
self.assertEqual(AccessToken.objects.all().count(), 0)
def test_revoke_user_deactivated(self):
"""Test revoke on logout"""
AccessToken.objects.create(
provider=self.provider,
user=self.user,
token=generate_id(),
auth_time=timezone.now(),
_scope="openid user profile",
_id_token=json.dumps(
asdict(
IDToken("foo", "bar"),
)
),
)
RefreshToken.objects.create(
provider=self.provider,
user=self.user,
token=generate_id(),
auth_time=timezone.now(),
_scope="openid user profile",
_id_token=json.dumps(
asdict(
IDToken("foo", "bar"),
)
),
)
DeviceToken.objects.create(
provider=self.provider,
user=self.user,
_scope="openid user profile",
)
self.user.is_active = False
self.user.save()
self.assertEqual(AccessToken.objects.all().count(), 0)
self.assertEqual(RefreshToken.objects.all().count(), 0)
self.assertEqual(DeviceToken.objects.all().count(), 0)

View File

@ -15,7 +15,7 @@ from django.utils import timezone
from django.utils.translation import gettext as _
from structlog.stdlib import get_logger
from authentik.core.models import Application, AuthenticatedSession
from authentik.core.models import Application
from authentik.events.models import Event, EventAction
from authentik.events.signals import get_login_event
from authentik.flows.challenge import (
@ -30,7 +30,7 @@ from authentik.flows.stage import StageView
from authentik.lib.utils.time import timedelta_from_string
from authentik.lib.views import bad_request_message
from authentik.policies.types import PolicyRequest
from authentik.policies.views import BufferedPolicyAccessView, RequestValidationError
from authentik.policies.views import PolicyAccessView, RequestValidationError
from authentik.providers.oauth2.constants import (
PKCE_METHOD_PLAIN,
PKCE_METHOD_S256,
@ -316,9 +316,7 @@ class OAuthAuthorizationParams:
expires=now + timedelta_from_string(self.provider.access_code_validity),
scope=self.scope,
nonce=self.nonce,
session=AuthenticatedSession.objects.filter(
session_key=request.session.session_key
).first(),
session=request.session["authenticatedsession"],
)
if self.code_challenge and self.code_challenge_method:
@ -328,7 +326,7 @@ class OAuthAuthorizationParams:
return code
class AuthorizationFlowInitView(BufferedPolicyAccessView):
class AuthorizationFlowInitView(PolicyAccessView):
"""OAuth2 Flow initializer, checks access to application and starts flow"""
params: OAuthAuthorizationParams
@ -615,9 +613,7 @@ class OAuthFulfillmentStage(StageView):
expires=access_token_expiry,
provider=self.provider,
auth_time=auth_event.created if auth_event else now,
session=AuthenticatedSession.objects.filter(
session_key=self.request.session.session_key
).first(),
session=self.request.session["authenticatedsession"],
)
id_token = IDToken.new(self.provider, token, self.request)

View File

@ -20,4 +20,4 @@ def logout_proxy_revoke_direct(sender: type[User], request: HttpRequest, **_):
@receiver(pre_delete, sender=AuthenticatedSession)
def logout_proxy_revoke(sender: type[AuthenticatedSession], instance: AuthenticatedSession, **_):
"""Catch logout by expiring sessions being deleted"""
proxy_on_logout.delay(instance.session_key)
proxy_on_logout.delay(instance.session.session_key)

View File

@ -0,0 +1,60 @@
# Generated by Django 5.0.11 on 2025-01-27 12:59
from django.db import migrations
import django.db.models.deletion
from django.db import migrations, models
def migrate_sessions(apps, schema_editor):
ConnectionToken = apps.get_model("authentik_providers_rac", "ConnectionToken")
AuthenticatedSession = apps.get_model("authentik_core", "AuthenticatedSession")
db_alias = schema_editor.connection.alias
for token in ConnectionToken.objects.using(db_alias).all():
token.session = (
AuthenticatedSession.objects.using(db_alias)
.filter(session_key=token.old_session.session_key)
.first()
)
if token.session:
token.save()
else:
token.delete()
class Migration(migrations.Migration):
dependencies = [
("authentik_providers_rac", "0006_connectiontoken_authentik_p_expires_91f148_idx_and_more"),
("authentik_core", "0046_session_and_more"),
]
operations = [
migrations.RenameField(
model_name="connectiontoken",
old_name="session",
new_name="old_session",
),
migrations.AddField(
model_name="connectiontoken",
name="session",
field=models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.CASCADE,
to="authentik_core.authenticatedsession",
),
),
migrations.RunPython(code=migrate_sessions),
migrations.AlterField(
model_name="connectiontoken",
name="session",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to="authentik_core.authenticatedsession",
),
),
migrations.RemoveField(
model_name="connectiontoken",
name="old_session",
),
]

View File

@ -8,7 +8,7 @@ from django.db.models.signals import post_delete, post_save, pre_delete
from django.dispatch import receiver
from django.http import HttpRequest
from authentik.core.models import User
from authentik.core.models import AuthenticatedSession, User
from authentik.providers.rac.api.endpoints import user_endpoint_cache_key
from authentik.providers.rac.consumer_client import (
RAC_CLIENT_GROUP_SESSION,
@ -32,6 +32,18 @@ def user_logged_out_session(sender, request: HttpRequest, user: User, **_):
)
@receiver(pre_delete, sender=AuthenticatedSession)
def user_session_deleted(sender, instance: AuthenticatedSession, **_):
layer = get_channel_layer()
async_to_sync(layer.group_send)(
RAC_CLIENT_GROUP_SESSION
% {
"session": instance.session.session_key,
},
{"type": "event.disconnect", "reason": "session_logout"},
)
@receiver(pre_delete, sender=ConnectionToken)
def pre_delete_connection_token_disconnect(sender, instance: ConnectionToken, **_):
"""Disconnect session when connection token is deleted"""

View File

@ -2,7 +2,7 @@
from django.test import TransactionTestCase
from authentik.core.models import Application, AuthenticatedSession
from authentik.core.models import Application, AuthenticatedSession, Session
from authentik.core.tests.utils import create_test_admin_user
from authentik.lib.generators import generate_id
from authentik.providers.rac.models import (
@ -36,13 +36,15 @@ class TestModels(TransactionTestCase):
def test_settings_merge(self):
"""Test settings merge"""
session = Session.objects.create(
session_key=generate_id(),
last_ip="255.255.255.255",
)
auth_session = AuthenticatedSession.objects.create(session=session, user=self.user)
token = ConnectionToken.objects.create(
provider=self.provider,
endpoint=self.endpoint,
session=AuthenticatedSession.objects.create(
user=self.user,
session_key=generate_id(),
),
session=auth_session,
)
path = f"/tmp/connection/{token.token}" # nosec
self.assertEqual(

View File

@ -1,7 +1,5 @@
"""rac urls"""
from channels.auth import AuthMiddleware
from channels.sessions import CookieMiddleware
from django.urls import path
from authentik.outposts.channels import TokenOutpostMiddleware
@ -12,7 +10,7 @@ from authentik.providers.rac.api.providers import RACProviderViewSet
from authentik.providers.rac.consumer_client import RACClientConsumer
from authentik.providers.rac.consumer_outpost import RACOutpostConsumer
from authentik.providers.rac.views import RACInterface, RACStartView
from authentik.root.asgi_middleware import SessionMiddleware
from authentik.root.asgi_middleware import AuthMiddlewareStack
from authentik.root.middleware import ChannelsLoggingMiddleware
urlpatterns = [
@ -31,9 +29,7 @@ urlpatterns = [
websocket_urlpatterns = [
path(
"ws/rac/<str:token>/",
ChannelsLoggingMiddleware(
CookieMiddleware(SessionMiddleware(AuthMiddleware(RACClientConsumer.as_asgi())))
),
ChannelsLoggingMiddleware(AuthMiddlewareStack(RACClientConsumer.as_asgi())),
),
path(
"ws/outpost_rac/<str:channel>/",

View File

@ -8,7 +8,7 @@ from django.urls import reverse
from django.utils.timezone import now
from django.utils.translation import gettext as _
from authentik.core.models import Application, AuthenticatedSession
from authentik.core.models import Application
from authentik.core.views.interface import InterfaceView
from authentik.events.models import Event, EventAction
from authentik.flows.challenge import RedirectChallenge
@ -18,11 +18,11 @@ from authentik.flows.planner import PLAN_CONTEXT_APPLICATION, FlowPlanner
from authentik.flows.stage import RedirectStage
from authentik.lib.utils.time import timedelta_from_string
from authentik.policies.engine import PolicyEngine
from authentik.policies.views import BufferedPolicyAccessView
from authentik.policies.views import PolicyAccessView
from authentik.providers.rac.models import ConnectionToken, Endpoint, RACProvider
class RACStartView(BufferedPolicyAccessView):
class RACStartView(PolicyAccessView):
"""Start a RAC connection by checking access and creating a connection token"""
endpoint: Endpoint
@ -113,9 +113,7 @@ class RACFinalStage(RedirectStage):
provider=self.provider,
endpoint=self.endpoint,
settings=self.executor.plan.context.get("connection_settings", {}),
session=AuthenticatedSession.objects.filter(
session_key=self.request.session.session_key
).first(),
session=self.request.session["authenticatedsession"],
expires=now() + timedelta_from_string(self.provider.connection_expiry),
expiring=True,
)

View File

@ -15,7 +15,7 @@ from authentik.flows.models import in_memory_stage
from authentik.flows.planner import PLAN_CONTEXT_APPLICATION, PLAN_CONTEXT_SSO, FlowPlanner
from authentik.flows.views.executor import SESSION_KEY_POST
from authentik.lib.views import bad_request_message
from authentik.policies.views import BufferedPolicyAccessView
from authentik.policies.views import PolicyAccessView
from authentik.providers.saml.exceptions import CannotHandleAssertion
from authentik.providers.saml.models import SAMLBindings, SAMLProvider
from authentik.providers.saml.processors.authn_request_parser import AuthNRequestParser
@ -35,7 +35,7 @@ from authentik.stages.consent.stage import (
LOGGER = get_logger()
class SAMLSSOView(BufferedPolicyAccessView):
class SAMLSSOView(PolicyAccessView):
"""SAML SSO Base View, which plans a flow and injects our final stage.
Calls get/post handler."""
@ -83,7 +83,7 @@ class SAMLSSOView(BufferedPolicyAccessView):
def post(self, request: HttpRequest, application_slug: str) -> HttpResponse:
"""GET and POST use the same handler, but we can't
override .dispatch easily because BufferedPolicyAccessView's dispatch"""
override .dispatch easily because PolicyAccessView's dispatch"""
return self.get(request, application_slug)

View File

@ -0,0 +1,41 @@
"""RBAC Initial Permissions"""
from rest_framework.serializers import ListSerializer
from rest_framework.viewsets import ModelViewSet
from authentik.core.api.used_by import UsedByMixin
from authentik.core.api.utils import ModelSerializer
from authentik.rbac.api.rbac import PermissionSerializer
from authentik.rbac.models import InitialPermissions
class InitialPermissionsSerializer(ModelSerializer):
"""InitialPermissions serializer"""
permissions_obj = ListSerializer(
child=PermissionSerializer(),
read_only=True,
source="permissions",
required=False,
)
class Meta:
model = InitialPermissions
fields = [
"pk",
"name",
"mode",
"role",
"permissions",
"permissions_obj",
]
class InitialPermissionsViewSet(UsedByMixin, ModelViewSet):
"""InitialPermissions viewset"""
queryset = InitialPermissions.objects.all()
serializer_class = InitialPermissionsSerializer
search_fields = ["name"]
ordering = ["name"]
filterset_fields = ["name"]

View File

@ -0,0 +1,39 @@
# Generated by Django 5.0.13 on 2025-04-07 13:05
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("auth", "0012_alter_user_first_name_max_length"),
("authentik_rbac", "0004_alter_systempermission_options"),
]
operations = [
migrations.CreateModel(
name="InitialPermissions",
fields=[
(
"id",
models.AutoField(
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
),
),
("name", models.TextField(max_length=150, unique=True)),
("mode", models.CharField(choices=[("user", "User"), ("role", "Role")])),
("permissions", models.ManyToManyField(blank=True, to="auth.permission")),
(
"role",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to="authentik_rbac.role"
),
),
],
options={
"verbose_name": "Initial Permissions",
"verbose_name_plural": "Initial Permissions",
},
),
]

View File

@ -3,6 +3,7 @@
from uuid import uuid4
from django.contrib.auth.management import _get_all_permissions
from django.contrib.auth.models import Permission
from django.db import models
from django.db.transaction import atomic
from django.utils.translation import gettext_lazy as _
@ -75,6 +76,35 @@ class Role(SerializerModel):
]
class InitialPermissionsMode(models.TextChoices):
"""Determines which entity the initial permissions are assigned to."""
USER = "user", _("User")
ROLE = "role", _("Role")
class InitialPermissions(SerializerModel):
"""Assigns permissions for newly created objects."""
name = models.TextField(max_length=150, unique=True)
mode = models.CharField(choices=InitialPermissionsMode.choices)
role = models.ForeignKey(Role, on_delete=models.CASCADE)
permissions = models.ManyToManyField(Permission, blank=True)
@property
def serializer(self) -> type[BaseSerializer]:
from authentik.rbac.api.initial_permissions import InitialPermissionsSerializer
return InitialPermissionsSerializer
def __str__(self) -> str:
return f"Initial Permissions for Role #{self.role_id}, applying to #{self.mode}"
class Meta:
verbose_name = _("Initial Permissions")
verbose_name_plural = _("Initial Permissions")
class SystemPermission(models.Model):
"""System-wide permissions that are not related to any direct
database model"""

View File

@ -1,9 +1,13 @@
"""RBAC Permissions"""
from django.contrib.contenttypes.models import ContentType
from django.db.models import Model
from guardian.shortcuts import assign_perm
from rest_framework.permissions import BasePermission, DjangoObjectPermissions
from rest_framework.request import Request
from authentik.rbac.models import InitialPermissions, InitialPermissionsMode
class ObjectPermissions(DjangoObjectPermissions):
"""RBAC Permissions"""
@ -51,3 +55,20 @@ def HasPermission(*perm: str) -> type[BasePermission]:
return bool(request.user and request.user.has_perms(perm))
return checker
# TODO: add `user: User` type annotation without circular dependencies.
# The author of this function isn't proficient/patient enough to do it.
def assign_initial_permissions(user, instance: Model):
# Performance here should not be an issue, but if needed, there are many optimization routes
initial_permissions_list = InitialPermissions.objects.filter(role__group__in=user.groups.all())
for initial_permissions in initial_permissions_list:
for permission in initial_permissions.permissions.all():
if permission.content_type != ContentType.objects.get_for_model(instance):
continue
assign_to = (
user
if initial_permissions.mode == InitialPermissionsMode.USER
else initial_permissions.role.group
)
assign_perm(permission, assign_to, instance)

View File

@ -0,0 +1,116 @@
"""Test InitialPermissions"""
from django.contrib.auth.models import Permission
from guardian.shortcuts import assign_perm
from rest_framework.reverse import reverse
from rest_framework.test import APITestCase
from authentik.core.models import Group
from authentik.core.tests.utils import create_test_user
from authentik.lib.generators import generate_id
from authentik.rbac.models import InitialPermissions, InitialPermissionsMode, Role
from authentik.stages.dummy.models import DummyStage
class TestInitialPermissions(APITestCase):
"""Test InitialPermissions"""
def setUp(self) -> None:
self.user = create_test_user()
self.same_role_user = create_test_user()
self.different_role_user = create_test_user()
self.role = Role.objects.create(name=generate_id())
self.different_role = Role.objects.create(name=generate_id())
self.group = Group.objects.create(name=generate_id())
self.different_group = Group.objects.create(name=generate_id())
self.group.roles.add(self.role)
self.group.users.add(self.user, self.same_role_user)
self.different_group.roles.add(self.different_role)
self.different_group.users.add(self.different_role_user)
self.ip = InitialPermissions.objects.create(
name=generate_id(), mode=InitialPermissionsMode.USER, role=self.role
)
self.view_role = Permission.objects.filter(codename="view_role").first()
self.ip.permissions.add(self.view_role)
assign_perm("authentik_rbac.add_role", self.user)
self.client.force_login(self.user)
def test_different_role(self):
"""InitialPermissions for different role does nothing"""
self.ip.role = self.different_role
self.ip.save()
self.client.post(reverse("authentik_api:roles-list"), {"name": "test-role"})
role = Role.objects.filter(name="test-role").first()
self.assertFalse(self.user.has_perm("authentik_rbac.view_role", role))
def test_different_model(self):
"""InitialPermissions for different model does nothing"""
assign_perm("authentik_stages_dummy.add_dummystage", self.user)
self.client.post(
reverse("authentik_api:stages-dummy-list"), {"name": "test-stage", "throw-error": False}
)
role = Role.objects.filter(name="test-role").first()
self.assertFalse(self.user.has_perm("authentik_rbac.view_role", role))
stage = DummyStage.objects.filter(name="test-stage").first()
self.assertFalse(self.user.has_perm("authentik_stages_dummy.view_dummystage", stage))
def test_mode_user(self):
"""InitialPermissions adds user permission in user mode"""
self.client.post(reverse("authentik_api:roles-list"), {"name": "test-role"})
role = Role.objects.filter(name="test-role").first()
self.assertTrue(self.user.has_perm("authentik_rbac.view_role", role))
self.assertFalse(self.same_role_user.has_perm("authentik_rbac.view_role", role))
def test_mode_role(self):
"""InitialPermissions adds role permission in role mode"""
self.ip.mode = InitialPermissionsMode.ROLE
self.ip.save()
self.client.post(reverse("authentik_api:roles-list"), {"name": "test-role"})
role = Role.objects.filter(name="test-role").first()
self.assertTrue(self.user.has_perm("authentik_rbac.view_role", role))
self.assertTrue(self.same_role_user.has_perm("authentik_rbac.view_role", role))
def test_many_permissions(self):
"""InitialPermissions can add multiple permissions"""
change_role = Permission.objects.filter(codename="change_role").first()
self.ip.permissions.add(change_role)
self.client.post(reverse("authentik_api:roles-list"), {"name": "test-role"})
role = Role.objects.filter(name="test-role").first()
self.assertTrue(self.user.has_perm("authentik_rbac.view_role", role))
self.assertTrue(self.user.has_perm("authentik_rbac.change_role", role))
def test_permissions_separated_by_role(self):
"""When the triggering user is part of two different roles with InitialPermissions in role
mode, it only adds permissions to the relevant role."""
self.ip.mode = InitialPermissionsMode.ROLE
self.ip.save()
different_ip = InitialPermissions.objects.create(
name=generate_id(), mode=InitialPermissionsMode.ROLE, role=self.different_role
)
change_role = Permission.objects.filter(codename="change_role").first()
different_ip.permissions.add(change_role)
self.different_group.users.add(self.user)
self.client.post(reverse("authentik_api:roles-list"), {"name": "test-role"})
role = Role.objects.filter(name="test-role").first()
self.assertTrue(self.user.has_perm("authentik_rbac.view_role", role))
self.assertTrue(self.same_role_user.has_perm("authentik_rbac.view_role", role))
self.assertFalse(self.different_role_user.has_perm("authentik_rbac.view_role", role))
self.assertTrue(self.user.has_perm("authentik_rbac.change_role", role))
self.assertFalse(self.same_role_user.has_perm("authentik_rbac.change_role", role))
self.assertTrue(self.different_role_user.has_perm("authentik_rbac.change_role", role))

View File

@ -1,5 +1,6 @@
"""RBAC API urls"""
from authentik.rbac.api.initial_permissions import InitialPermissionsViewSet
from authentik.rbac.api.rbac import RBACPermissionViewSet
from authentik.rbac.api.rbac_assigned_by_roles import RoleAssignedPermissionViewSet
from authentik.rbac.api.rbac_assigned_by_users import UserAssignedPermissionViewSet
@ -21,5 +22,6 @@ api_urlpatterns = [
("rbac/permissions/users", UserPermissionViewSet, "permissions-users"),
("rbac/permissions/roles", RolePermissionViewSet, "permissions-roles"),
("rbac/permissions", RBACPermissionViewSet),
("rbac/roles", RoleViewSet),
("rbac/roles", RoleViewSet, "roles"),
("rbac/initial_permissions", InitialPermissionsViewSet, "initial-permissions"),
]

View File

@ -50,7 +50,7 @@ class TestRecovery(TestCase):
)
token = Token.objects.get(intent=TokenIntents.INTENT_RECOVERY, user=self.user)
self.client.get(reverse("authentik_recovery:use-token", kwargs={"key": token.key}))
self.assertEqual(int(self.client.session["_auth_user_id"]), token.user.pk)
self.assertEqual(self.client.session["authenticatedsession"].user.pk, token.user.pk)
def test_recovery_view_invalid(self):
"""Test recovery view with invalid token"""

View File

@ -1,8 +1,12 @@
"""ASGI middleware"""
from channels.auth import UserLazyObject
from channels.db import database_sync_to_async
from channels.middleware import BaseMiddleware
from channels.sessions import CookieMiddleware
from channels.sessions import InstanceSessionWrapper as UpstreamInstanceSessionWrapper
from channels.sessions import SessionMiddleware as UpstreamSessionMiddleware
from django.contrib.auth.models import AnonymousUser
from authentik.root.middleware import SessionMiddleware as HTTPSessionMiddleware
@ -33,3 +37,48 @@ class SessionMiddleware(UpstreamSessionMiddleware):
await wrapper.resolve_session()
return await self.inner(wrapper.scope, receive, wrapper.send)
@database_sync_to_async
def get_user(scope):
"""
Return the user model instance associated with the given scope.
If no user is retrieved, return an instance of `AnonymousUser`.
"""
if "session" not in scope:
raise ValueError(
"Cannot find session in scope. You should wrap your consumer in SessionMiddleware."
)
user = None
if (authenticated_session := scope["session"].get("authenticated_session", None)) is not None:
user = authenticated_session.user
return user or AnonymousUser()
class AuthMiddleware(BaseMiddleware):
def populate_scope(self, scope):
# Make sure we have a session
if "session" not in scope:
raise ValueError(
"AuthMiddleware cannot find session in scope. SessionMiddleware must be above it."
)
# Add it to the scope if it's not there already
if "user" not in scope:
scope["user"] = UserLazyObject()
async def resolve_scope(self, scope):
scope["user"]._wrapped = await get_user(scope)
async def __call__(self, scope, receive, send):
scope = dict(scope)
# Scope injection/mutation per this middleware's needs.
self.populate_scope(scope)
# Grab the finalized/resolved scope
await self.resolve_scope(scope)
return await super().__call__(scope, receive, send)
# Handy shortcut for applying all three layers at once
def AuthMiddlewareStack(inner):
return CookieMiddleware(SessionMiddleware(AuthMiddleware(inner)))

View File

@ -49,7 +49,7 @@ class SessionMiddleware(UpstreamSessionMiddleware):
return False
@staticmethod
def decode_session_key(key: str) -> str:
def decode_session_key(key: str | None) -> str | None:
"""Decode raw session cookie, and parse JWT"""
# We need to support the standard django format of just a session key
# for testing setups, where the session is directly set
@ -64,7 +64,11 @@ class SessionMiddleware(UpstreamSessionMiddleware):
def process_request(self, request: HttpRequest):
raw_session = request.COOKIES.get(settings.SESSION_COOKIE_NAME)
session_key = SessionMiddleware.decode_session_key(raw_session)
request.session = self.SessionStore(session_key)
request.session = self.SessionStore(
session_key,
last_ip=ClientIPMiddleware.get_client_ip(request),
last_user_agent=request.META.get("HTTP_USER_AGENT", ""),
)
def process_response(self, request: HttpRequest, response: HttpResponse) -> HttpResponse:
"""

View File

@ -1,23 +0,0 @@
"""
Module for abstract serializer/unserializer base classes.
"""
import pickle # nosec
class PickleSerializer:
"""
Simple wrapper around pickle to be used in signing.dumps()/loads() and
cache backends.
"""
def __init__(self, protocol=None):
self.protocol = pickle.HIGHEST_PROTOCOL if protocol is None else protocol
def dumps(self, obj):
"""Pickle data to be stored in redis"""
return pickle.dumps(obj, self.protocol)
def loads(self, data):
"""Unpickle data to be loaded from redis"""
return pickle.loads(data) # nosec

View File

@ -7,7 +7,6 @@ from pathlib import Path
import orjson
from celery.schedules import crontab
from django.conf import ImproperlyConfigured
from sentry_sdk import set_tag
from xmlsec import enable_debug_trace
@ -43,7 +42,6 @@ SESSION_COOKIE_DOMAIN = CONFIG.get("cookie_domain", None)
APPEND_SLASH = False
AUTHENTICATION_BACKENDS = [
"django.contrib.auth.backends.ModelBackend",
BACKEND_INBUILT,
BACKEND_APP_PASSWORD,
BACKEND_LDAP,
@ -229,17 +227,7 @@ CACHES = {
DJANGO_REDIS_SCAN_ITERSIZE = 1000
DJANGO_REDIS_IGNORE_EXCEPTIONS = True
DJANGO_REDIS_LOG_IGNORED_EXCEPTIONS = True
match CONFIG.get("session_storage", "cache"):
case "cache":
SESSION_ENGINE = "django.contrib.sessions.backends.cache"
case "db":
SESSION_ENGINE = "django.contrib.sessions.backends.db"
case _:
raise ImproperlyConfigured(
"Invalid session_storage setting, allowed values are db and cache"
)
SESSION_SERIALIZER = "authentik.root.sessions.pickle.PickleSerializer"
SESSION_CACHE_ALIAS = "default"
SESSION_ENGINE = "authentik.core.sessions"
# Configured via custom SessionMiddleware
# SESSION_COOKIE_SAMESITE = "None"
# SESSION_COOKIE_SECURE = True
@ -256,7 +244,7 @@ MIDDLEWARE = [
"django_prometheus.middleware.PrometheusBeforeMiddleware",
"authentik.root.middleware.ClientIPMiddleware",
"authentik.stages.user_login.middleware.BoundSessionMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"authentik.core.middleware.AuthenticationMiddleware",
"authentik.core.middleware.RequestIDMiddleware",
"authentik.brands.middleware.BrandMiddleware",
"authentik.events.middleware.AuditMiddleware",

View File

@ -15,11 +15,22 @@ from rest_framework.response import Response
from rest_framework.viewsets import ModelViewSet
from authentik.core.api.property_mappings import PropertyMappingFilterSet, PropertyMappingSerializer
from authentik.core.api.sources import SourceSerializer
from authentik.core.api.sources import (
GroupSourceConnectionSerializer,
GroupSourceConnectionViewSet,
SourceSerializer,
UserSourceConnectionSerializer,
UserSourceConnectionViewSet,
)
from authentik.core.api.used_by import UsedByMixin
from authentik.crypto.models import CertificateKeyPair
from authentik.lib.sync.outgoing.api import SyncStatusSerializer
from authentik.sources.ldap.models import LDAPSource, LDAPSourcePropertyMapping
from authentik.sources.ldap.models import (
GroupLDAPSourceConnection,
LDAPSource,
LDAPSourcePropertyMapping,
UserLDAPSourceConnection,
)
from authentik.sources.ldap.tasks import CACHE_KEY_STATUS, SYNC_CLASSES
@ -221,3 +232,23 @@ class LDAPSourcePropertyMappingViewSet(UsedByMixin, ModelViewSet):
filterset_class = LDAPSourcePropertyMappingFilter
search_fields = ["name"]
ordering = ["name"]
class UserLDAPSourceConnectionSerializer(UserSourceConnectionSerializer):
class Meta(UserSourceConnectionSerializer.Meta):
model = UserLDAPSourceConnection
class UserLDAPSourceConnectionViewSet(UserSourceConnectionViewSet, ModelViewSet):
queryset = UserLDAPSourceConnection.objects.all()
serializer_class = UserLDAPSourceConnectionSerializer
class GroupLDAPSourceConnectionSerializer(GroupSourceConnectionSerializer):
class Meta(GroupSourceConnectionSerializer.Meta):
model = GroupLDAPSourceConnection
class GroupLDAPSourceConnectionViewSet(GroupSourceConnectionViewSet, ModelViewSet):
queryset = GroupLDAPSourceConnection.objects.all()
serializer_class = GroupLDAPSourceConnectionSerializer

View File

@ -0,0 +1,57 @@
# Generated by Django 5.0.14 on 2025-04-11 11:46
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("authentik_core", "0047_delete_oldauthenticatedsession"),
("authentik_sources_ldap", "0007_ldapsource_lookup_groups_from_user"),
]
operations = [
migrations.CreateModel(
name="GroupLDAPSourceConnection",
fields=[
(
"groupsourceconnection_ptr",
models.OneToOneField(
auto_created=True,
on_delete=django.db.models.deletion.CASCADE,
parent_link=True,
primary_key=True,
serialize=False,
to="authentik_core.groupsourceconnection",
),
),
],
options={
"verbose_name": "Group LDAP Source Connection",
"verbose_name_plural": "Group LDAP Source Connections",
},
bases=("authentik_core.groupsourceconnection",),
),
migrations.CreateModel(
name="UserLDAPSourceConnection",
fields=[
(
"usersourceconnection_ptr",
models.OneToOneField(
auto_created=True,
on_delete=django.db.models.deletion.CASCADE,
parent_link=True,
primary_key=True,
serialize=False,
to="authentik_core.usersourceconnection",
),
),
],
options={
"verbose_name": "User LDAP Source Connection",
"verbose_name_plural": "User LDAP Source Connections",
},
bases=("authentik_core.usersourceconnection",),
),
]

View File

@ -15,7 +15,13 @@ from ldap3 import ALL, NONE, RANDOM, Connection, Server, ServerPool, Tls
from ldap3.core.exceptions import LDAPException, LDAPInsufficientAccessRightsResult, LDAPSchemaError
from rest_framework.serializers import Serializer
from authentik.core.models import Group, PropertyMapping, Source
from authentik.core.models import (
Group,
GroupSourceConnection,
PropertyMapping,
Source,
UserSourceConnection,
)
from authentik.crypto.models import CertificateKeyPair
from authentik.lib.config import CONFIG
from authentik.lib.models import DomainlessURLValidator
@ -312,3 +318,31 @@ class LDAPSourcePropertyMapping(PropertyMapping):
class Meta:
verbose_name = _("LDAP Source Property Mapping")
verbose_name_plural = _("LDAP Source Property Mappings")
class UserLDAPSourceConnection(UserSourceConnection):
@property
def serializer(self) -> type[Serializer]:
from authentik.sources.ldap.api import (
UserLDAPSourceConnectionSerializer,
)
return UserLDAPSourceConnectionSerializer
class Meta:
verbose_name = _("User LDAP Source Connection")
verbose_name_plural = _("User LDAP Source Connections")
class GroupLDAPSourceConnection(GroupSourceConnection):
@property
def serializer(self) -> type[Serializer]:
from authentik.sources.ldap.api import (
GroupLDAPSourceConnectionSerializer,
)
return GroupLDAPSourceConnectionSerializer
class Meta:
verbose_name = _("Group LDAP Source Connection")
verbose_name_plural = _("Group LDAP Source Connections")

View File

@ -14,7 +14,12 @@ from authentik.core.models import Group
from authentik.core.sources.mapper import SourceMapper
from authentik.events.models import Event, EventAction
from authentik.lib.sync.outgoing.exceptions import StopSync
from authentik.sources.ldap.models import LDAP_UNIQUENESS, LDAPSource, flatten
from authentik.sources.ldap.models import (
LDAP_UNIQUENESS,
GroupLDAPSourceConnection,
LDAPSource,
flatten,
)
from authentik.sources.ldap.sync.base import BaseLDAPSynchronizer
@ -89,6 +94,12 @@ class GroupLDAPSynchronizer(BaseLDAPSynchronizer):
defaults,
)
self._logger.debug("Created group with attributes", **defaults)
if not GroupLDAPSourceConnection.objects.filter(
source=self._source, identifier=uniq
):
GroupLDAPSourceConnection.objects.create(
source=self._source, group=ak_group, identifier=uniq
)
except SkipObjectException:
continue
except PropertyMappingExpressionException as exc:

View File

@ -14,7 +14,12 @@ from authentik.core.models import User
from authentik.core.sources.mapper import SourceMapper
from authentik.events.models import Event, EventAction
from authentik.lib.sync.outgoing.exceptions import StopSync
from authentik.sources.ldap.models import LDAP_UNIQUENESS, LDAPSource, flatten
from authentik.sources.ldap.models import (
LDAP_UNIQUENESS,
LDAPSource,
UserLDAPSourceConnection,
flatten,
)
from authentik.sources.ldap.sync.base import BaseLDAPSynchronizer
from authentik.sources.ldap.sync.vendor.freeipa import FreeIPA
from authentik.sources.ldap.sync.vendor.ms_ad import MicrosoftActiveDirectory
@ -85,6 +90,12 @@ class UserLDAPSynchronizer(BaseLDAPSynchronizer):
ak_user, created = User.update_or_create_attributes(
{f"attributes__{LDAP_UNIQUENESS}": uniq}, defaults
)
if not UserLDAPSourceConnection.objects.filter(
source=self._source, identifier=uniq
):
UserLDAPSourceConnection.objects.create(
source=self._source, user=ak_user, identifier=uniq
)
except PropertyMappingExpressionException as exc:
raise StopSync(exc, None, exc.mapping) from exc
except SkipObjectException:

View File

@ -1,8 +1,15 @@
"""API URLs"""
from authentik.sources.ldap.api import LDAPSourcePropertyMappingViewSet, LDAPSourceViewSet
from authentik.sources.ldap.api import (
GroupLDAPSourceConnectionViewSet,
LDAPSourcePropertyMappingViewSet,
LDAPSourceViewSet,
UserLDAPSourceConnectionViewSet,
)
api_urlpatterns = [
("propertymappings/source/ldap", LDAPSourcePropertyMappingViewSet),
("sources/ldap", LDAPSourceViewSet),
("sources/user_connections/ldap", UserLDAPSourceConnectionViewSet),
("sources/group_connections/ldap", GroupLDAPSourceConnectionViewSet),
]

View File

@ -130,6 +130,7 @@ class OAuthSourceSerializer(SourceSerializer):
"oidc_well_known_url",
"oidc_jwks_url",
"oidc_jwks",
"authorization_code_auth_method",
]
extra_kwargs = {
"consumer_secret": {"write_only": True},

View File

@ -6,11 +6,15 @@ from urllib.parse import parse_qsl
from django.utils.crypto import constant_time_compare, get_random_string
from django.utils.translation import gettext as _
from requests.auth import AuthBase, HTTPBasicAuth
from requests.exceptions import RequestException
from requests.models import Response
from structlog.stdlib import get_logger
from authentik.sources.oauth.clients.base import BaseOAuthClient
from authentik.sources.oauth.models import (
AuthorizationCodeAuthMethod,
)
LOGGER = get_logger()
SESSION_KEY_OAUTH_PKCE = "authentik/sources/oauth/pkce"
@ -55,6 +59,30 @@ class OAuth2Client(BaseOAuthClient):
"""Get client secret"""
return self.source.consumer_secret
def get_access_token_args(self, callback: str, code: str) -> dict[str, Any]:
args = {
"redirect_uri": callback,
"code": code,
"grant_type": "authorization_code",
}
if SESSION_KEY_OAUTH_PKCE in self.request.session:
args["code_verifier"] = self.request.session[SESSION_KEY_OAUTH_PKCE]
if (
self.source.source_type.authorization_code_auth_method
== AuthorizationCodeAuthMethod.POST_BODY
):
args["client_id"] = self.get_client_id()
args["client_secret"] = self.get_client_secret()
return args
def get_access_token_auth(self) -> AuthBase | None:
if (
self.source.source_type.authorization_code_auth_method
== AuthorizationCodeAuthMethod.BASIC_AUTH
):
return HTTPBasicAuth(self.get_client_id(), self.get_client_secret())
return None
def get_access_token(self, **request_kwargs) -> dict[str, Any] | None:
"""Fetch access token from callback request."""
callback = self.request.build_absolute_uri(self.callback or self.request.path)
@ -67,13 +95,6 @@ class OAuth2Client(BaseOAuthClient):
error = self.get_request_arg("error", None)
error_desc = self.get_request_arg("error_description", None)
return {"error": error_desc or error or _("No token received.")}
args = {
"redirect_uri": callback,
"code": code,
"grant_type": "authorization_code",
}
if SESSION_KEY_OAUTH_PKCE in self.request.session:
args["code_verifier"] = self.request.session[SESSION_KEY_OAUTH_PKCE]
try:
access_token_url = self.source.source_type.access_token_url or ""
if self.source.source_type.urls_customizable and self.source.access_token_url:
@ -81,8 +102,8 @@ class OAuth2Client(BaseOAuthClient):
response = self.do_request(
"post",
access_token_url,
auth=(self.get_client_id(), self.get_client_secret()),
data=args,
auth=self.get_access_token_auth(),
data=self.get_access_token_args(callback, code),
headers=self._default_headers,
**request_kwargs,
)

View File

@ -0,0 +1,25 @@
# Generated by Django 5.0.14 on 2025-04-11 18:09
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("authentik_sources_oauth", "0009_migrate_useroauthsourceconnection_identifier"),
]
operations = [
migrations.AddField(
model_name="oauthsource",
name="authorization_code_auth_method",
field=models.TextField(
choices=[
("basic_auth", "HTTP Basic Authentication"),
("post_body", "Include the client ID and secret as request parameters"),
],
default="basic_auth",
help_text="How to perform authentication during an authorization_code token request flow",
),
),
]

View File

@ -21,6 +21,11 @@ if TYPE_CHECKING:
from authentik.sources.oauth.types.registry import SourceType
class AuthorizationCodeAuthMethod(models.TextChoices):
BASIC_AUTH = "basic_auth", _("HTTP Basic Authentication")
POST_BODY = "post_body", _("Include the client ID and secret as request parameters")
class OAuthSource(NonCreatableType, Source):
"""Login using a Generic OAuth provider."""
@ -61,6 +66,14 @@ class OAuthSource(NonCreatableType, Source):
oidc_jwks_url = models.TextField(default="", blank=True)
oidc_jwks = models.JSONField(default=dict, blank=True)
authorization_code_auth_method = models.TextField(
choices=AuthorizationCodeAuthMethod.choices,
default=AuthorizationCodeAuthMethod.BASIC_AUTH,
help_text=_(
"How to perform authentication during an authorization_code token request flow"
),
)
@property
def source_type(self) -> type["SourceType"]:
"""Return the provider instance for this source"""

View File

@ -0,0 +1,69 @@
from django.test import RequestFactory, TestCase
from guardian.shortcuts import get_anonymous_user
from authentik.lib.generators import generate_id
from authentik.sources.oauth.clients.oauth2 import OAuth2Client
from authentik.sources.oauth.models import AuthorizationCodeAuthMethod, OAuthSource
from authentik.sources.oauth.types.oidc import OpenIDConnectClient
class TestOAuthClient(TestCase):
"""OAuth Source tests"""
def setUp(self):
self.source = OAuthSource.objects.create(
name="test",
slug="test",
provider_type="openidconnect",
authorization_url="",
profile_url="",
consumer_key=generate_id(),
)
self.factory = RequestFactory()
def test_client_post_body_auth(self):
"""Test login_challenge"""
self.source.provider_type = "apple"
self.source.save()
request = self.factory.get("/")
request.session = {}
request.user = get_anonymous_user()
client = OAuth2Client(self.source, request)
self.assertIsNone(client.get_access_token_auth())
args = client.get_access_token_args("", "")
self.assertIn("client_id", args)
self.assertIn("client_secret", args)
def test_client_basic_auth(self):
"""Test login_challenge"""
self.source.provider_type = "reddit"
self.source.save()
request = self.factory.get("/")
request.session = {}
request.user = get_anonymous_user()
client = OAuth2Client(self.source, request)
self.assertIsNotNone(client.get_access_token_auth())
args = client.get_access_token_args("", "")
self.assertNotIn("client_id", args)
self.assertNotIn("client_secret", args)
def test_client_openid_auth(self):
"""Test login_challenge"""
request = self.factory.get("/")
request.session = {}
request.user = get_anonymous_user()
client = OpenIDConnectClient(self.source, request)
self.assertIsNotNone(client.get_access_token_auth())
args = client.get_access_token_args("", "")
self.assertNotIn("client_id", args)
self.assertNotIn("client_secret", args)
self.source.authorization_code_auth_method = AuthorizationCodeAuthMethod.POST_BODY
self.source.save()
client = OpenIDConnectClient(self.source, request)
self.assertIsNone(client.get_access_token_auth())
args = client.get_access_token_args("", "")
self.assertIn("client_id", args)
self.assertIn("client_secret", args)

View File

@ -11,7 +11,7 @@ from structlog.stdlib import get_logger
from authentik.flows.challenge import Challenge, ChallengeResponse
from authentik.sources.oauth.clients.oauth2 import OAuth2Client
from authentik.sources.oauth.models import OAuthSource
from authentik.sources.oauth.models import AuthorizationCodeAuthMethod, OAuthSource
from authentik.sources.oauth.types.registry import SourceType, registry
from authentik.sources.oauth.views.callback import OAuthCallback
from authentik.sources.oauth.views.redirect import OAuthRedirect
@ -105,6 +105,8 @@ class AppleType(SourceType):
access_token_url = "https://appleid.apple.com/auth/token" # nosec
profile_url = ""
authorization_code_auth_method = AuthorizationCodeAuthMethod.POST_BODY
def login_challenge(self, source: OAuthSource, request: HttpRequest) -> Challenge:
"""Pre-general all the things required for the JS SDK"""
apple_client = AppleOAuthClient(

View File

@ -6,6 +6,7 @@ from requests import RequestException
from structlog.stdlib import get_logger
from authentik.sources.oauth.clients.oauth2 import UserprofileHeaderAuthClient
from authentik.sources.oauth.models import AuthorizationCodeAuthMethod
from authentik.sources.oauth.types.oidc import OpenIDConnectOAuth2Callback
from authentik.sources.oauth.types.registry import SourceType, registry
from authentik.sources.oauth.views.redirect import OAuthRedirect
@ -77,6 +78,8 @@ class AzureADType(SourceType):
)
oidc_jwks_url = "https://login.microsoftonline.com/common/discovery/keys"
authorization_code_auth_method = AuthorizationCodeAuthMethod.POST_BODY
def get_base_user_properties(self, info: dict[str, Any], **kwargs) -> dict[str, Any]:
mail = info.get("mail", None) or info.get("otherMails", [None])[0]
# Format group info

View File

@ -2,8 +2,8 @@
from typing import Any
from authentik.sources.oauth.models import AuthorizationCodeAuthMethod
from authentik.sources.oauth.types.registry import SourceType, registry
from authentik.sources.oauth.views.callback import OAuthCallback
from authentik.sources.oauth.views.redirect import OAuthRedirect
@ -16,15 +16,10 @@ class FacebookOAuthRedirect(OAuthRedirect):
}
class FacebookOAuth2Callback(OAuthCallback):
"""Facebook OAuth2 Callback"""
@registry.register()
class FacebookType(SourceType):
"""Facebook Type definition"""
callback_view = FacebookOAuth2Callback
redirect_view = FacebookOAuthRedirect
verbose_name = "Facebook"
name = "facebook"
@ -33,6 +28,8 @@ class FacebookType(SourceType):
access_token_url = "https://graph.facebook.com/v7.0/oauth/access_token" # nosec
profile_url = "https://graph.facebook.com/v7.0/me?fields=id,name,email"
authorization_code_auth_method = AuthorizationCodeAuthMethod.POST_BODY
def get_base_user_properties(self, info: dict[str, Any], **kwargs) -> dict[str, Any]:
return {
"username": info.get("name"),

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