Compare commits
468 Commits
version/20
...
version/20
| Author | SHA1 | Date | |
|---|---|---|---|
| dae6493a3e | |||
| ad07984158 | |||
| f909b86338 | |||
| 327df6529b | |||
| 658dc63c4c | |||
| 4edec5f666 | |||
| d150a0c135 | |||
| d4242781a0 | |||
| 7369ca0b25 | |||
| 561f427cc5 | |||
| 8049ab703a | |||
| 9c2a97263a | |||
| 345504c1a4 | |||
| 549f6f2077 | |||
| 35c6decc75 | |||
| b3abeb78ff | |||
| 0562a1ad42 | |||
| febb0920fd | |||
| 549662beb0 | |||
| 1ea4440c5d | |||
| 787abdff5b | |||
| 2237807241 | |||
| e9d9d658c4 | |||
| e704092d19 | |||
| 305f72c197 | |||
| fb6b6b4476 | |||
| 791cc74dbb | |||
| 41f139589c | |||
| df24e3020b | |||
| e44c716cbe | |||
| d35302923d | |||
| 4d928368bc | |||
| c055d7a470 | |||
| 9e1b49e181 | |||
| db6a9ede1b | |||
| 86df0a448e | |||
| 5ec052bd92 | |||
| 6f7984d05a | |||
| f6d64d1d4b | |||
| ef0c7a5a57 | |||
| 34dfbf8e9e | |||
| 71d38e6fd0 | |||
| 9a9ba2560b | |||
| 2432e51970 | |||
| 47434cd62d | |||
| ff500b44a6 | |||
| 4c14c7f3a4 | |||
| 019c4bf182 | |||
| 2cbc291f04 | |||
| 5197a3a461 | |||
| 52be87785f | |||
| 8e19fb3a8c | |||
| 0448dcf655 | |||
| b8f74ab9e7 | |||
| 501ce5cebb | |||
| b896ca7ef6 | |||
| d497db3010 | |||
| 24f95fdeaa | |||
| d1c4818724 | |||
| 9f736a9d99 | |||
| 49cce6a968 | |||
| 713337130b | |||
| 0a73e7ac9f | |||
| 3344af72c2 | |||
| 41eb44137e | |||
| 94a9667d86 | |||
| 8b56a7defb | |||
| 5a4b9b4239 | |||
| f37308461c | |||
| 9721098178 | |||
| 0ca5e67dad | |||
| da94564d5e | |||
| 1f33237659 | |||
| 62e5979c13 | |||
| 8a1e18e087 | |||
| a951daddce | |||
| 690f6d444a | |||
| b733930745 | |||
| f316a3000b | |||
| ddae9dc6e1 | |||
| 0348d6558a | |||
| 6a497b32f6 | |||
| 47acc0ea90 | |||
| 967c952a4a | |||
| b648d159dd | |||
| aecd9387d9 | |||
| 6e8a5e1426 | |||
| 607899be56 | |||
| 5a92a8639a | |||
| 4cd629b5fc | |||
| 6020736430 | |||
| 14a4047bdd | |||
| 23c1e22a04 | |||
| 2a2ae4bc4f | |||
| 5f4812e1d0 | |||
| 3ab475d916 | |||
| 453d64eea5 | |||
| 17d33f4b19 | |||
| c39a5933e1 | |||
| a9636b5727 | |||
| 5e3f44dd87 | |||
| 1c64616ebd | |||
| 23273f53cc | |||
| d11ce0a86e | |||
| 766ceda57a | |||
| eb633c607e | |||
| c72d56d02d | |||
| e758c434ea | |||
| 90e3ae9457 | |||
| 0e825ffcfd | |||
| 8a19c71f62 | |||
| 5a7eff041a | |||
| 552459834a | |||
| cc6325bf6a | |||
| 9597ea9e1f | |||
| 69b5670659 | |||
| 56fd436e5d | |||
| b7558ae28c | |||
| ea60c389be | |||
| f6042f29f6 | |||
| 983882f5a0 | |||
| a6d3fd92df | |||
| 96f39904b8 | |||
| ee347aa7ef | |||
| 6437334e67 | |||
| 2f57d7f427 | |||
| db07f564aa | |||
| d1479a1b16 | |||
| 4d80e207da | |||
| e7be7ac9b4 | |||
| e0954c0f89 | |||
| 7ae061909c | |||
| 45a806f46b | |||
| feb6b07657 | |||
| 1d98582d29 | |||
| 06663edba2 | |||
| de0d1dc94d | |||
| 1652ea25e4 | |||
| d794e3055c | |||
| a92c68ac85 | |||
| dd41789230 | |||
| 022401b60e | |||
| ef218ff1ff | |||
| f933bf2f40 | |||
| 4fc761adea | |||
| d11c214d32 | |||
| ffbbe5ca5f | |||
| 8582091219 | |||
| 28c8eb3ee6 | |||
| 3a00a5ac3d | |||
| 20035e0f1b | |||
| 67021b0e7c | |||
| c5a2831665 | |||
| 768f073e49 | |||
| 504338ea66 | |||
| a8c04f96d2 | |||
| 340faf5341 | |||
| a76c39aff9 | |||
| bb728a53cc | |||
| 5c28a7dd44 | |||
| e1efb47543 | |||
| e50a296a18 | |||
| e211265c30 | |||
| 1f143a24db | |||
| 48f490b810 | |||
| aed382de0c | |||
| 8ecf40a58b | |||
| aca3c75e17 | |||
| f28509608b | |||
| ff6c508de7 | |||
| 7319ea2dcf | |||
| 6a4efaecb0 | |||
| 29b0eae43f | |||
| 9f3e742fb1 | |||
| c8e09fea33 | |||
| 437e932471 | |||
| ce07d71d23 | |||
| 9815c591e0 | |||
| db7a3ab630 | |||
| 3fa772c32e | |||
| 6c9dc7a15b | |||
| ece0429ea8 | |||
| d56ddb16b1 | |||
| b6267fdf28 | |||
| 1f190a9255 | |||
| 1f0fc0a6a2 | |||
| 3ba678851e | |||
| 0869ef3d0d | |||
| 91100ce1e2 | |||
| a65ce47736 | |||
| def17bbc1e | |||
| eb7da8f414 | |||
| 9201fc1834 | |||
| 5385feb428 | |||
| c6f29d9eb4 | |||
| db557401aa | |||
| c824af5bc3 | |||
| 1faba11a57 | |||
| f0c72e8536 | |||
| 91f91b08e5 | |||
| 8faa909c32 | |||
| 49142fa80b | |||
| 2a6fccd22a | |||
| 1d10afa209 | |||
| 4b7c3c38cd | |||
| 440cacbafe | |||
| b33bff92ee | |||
| caed306346 | |||
| d0eb6af7e9 | |||
| ec5ed67f6c | |||
| 59b899ddff | |||
| 85784f796c | |||
| 4c0e19cbea | |||
| b42eb9464f | |||
| 6559fdee15 | |||
| 3455bf3d27 | |||
| 0d96e68c1e | |||
| 29d3db5112 | |||
| cdf88e4477 | |||
| 7caac1d0c7 | |||
| 45364d6553 | |||
| 2298eb124f | |||
| 6dff1f8e5e | |||
| a944701f3a | |||
| 23866fe459 | |||
| 0a83b04419 | |||
| e6ecdf8b1d | |||
| 2d48fe42f4 | |||
| 5894ccdaf2 | |||
| 79bec6f6b2 | |||
| 9610f96c11 | |||
| 36a326cd81 | |||
| c0c222a0b8 | |||
| e17f7020e6 | |||
| 6d9579d3e6 | |||
| 9f15ee8cb8 | |||
| e892ed14da | |||
| 093a67525a | |||
| 1c62a3db6e | |||
| c4b4c7134d | |||
| 82cb6d41b8 | |||
| 423380d987 | |||
| 175d97fdcf | |||
| 5dbbf970b0 | |||
| 1541d477af | |||
| d745331654 | |||
| defbdc5068 | |||
| 350f0d8365 | |||
| b5c93fb3e3 | |||
| 5be45ebf8e | |||
| ad8fe9fe81 | |||
| c2f7edaa42 | |||
| 6821402fef | |||
| 8dbb0bd2c6 | |||
| 24a21c1761 | |||
| 0cad56ec73 | |||
| 4d8021c403 | |||
| 6573cbb16c | |||
| bdf76bb4b7 | |||
| 74ce9cc6fd | |||
| 070a6d866e | |||
| 5e2d647a6c | |||
| 7beebe030d | |||
| 66f4a31b4c | |||
| beddd6a460 | |||
| faec866581 | |||
| effed50cc1 | |||
| 38ad6096ad | |||
| bd53042553 | |||
| 039d896dee | |||
| ff2baf502b | |||
| 3b182ca223 | |||
| 8da8890a8e | |||
| 23023ec727 | |||
| 7d84a71a01 | |||
| 192001f193 | |||
| 63734682d2 | |||
| a0cd2d55f8 | |||
| a72c7adfc0 | |||
| e88e02ec85 | |||
| f7661c8bbd | |||
| 9add8479ca | |||
| 4c39e08dd4 | |||
| 44ce2ebece | |||
| f5a8859d00 | |||
| 9ef0e8bc5f | |||
| 60eeafd111 | |||
| 6f3d6efa22 | |||
| 8d3275817b | |||
| ca40d31dac | |||
| 438aac8879 | |||
| 2dfa6c2c82 | |||
| c11435780d | |||
| ee54328589 | |||
| 817d538b8f | |||
| 210775776f | |||
| 2a4ce75bc4 | |||
| b26111fb42 | |||
| e30103aa9f | |||
| dc9203789e | |||
| d70ce2776f | |||
| ad7d65e903 | |||
| 67d54c5209 | |||
| bb244b8338 | |||
| fa04883ac1 | |||
| 6739ded5a9 | |||
| 9a7e5d934e | |||
| 6dc6d19d2d | |||
| 36cbc44ed6 | |||
| 0c591a50e3 | |||
| 7ee655a318 | |||
| 8447e9b9c2 | |||
| 09f92e5bad | |||
| f9a419107a | |||
| 8f0572d11e | |||
| 7ebf793953 | |||
| 63783ee77b | |||
| eba339ba27 | |||
| 0adb5a79f6 | |||
| fa81adf254 | |||
| 558c7bba2a | |||
| 8cd1a42fb9 | |||
| 8cf0e78aa0 | |||
| 3f69a57013 | |||
| f7f12cab10 | |||
| cacaa378c8 | |||
| 33fe85eb96 | |||
| a9744cbf48 | |||
| b91d8a676c | |||
| f19cd1c003 | |||
| 65341cecd0 | |||
| c0cb891078 | |||
| fc1c1a849a | |||
| 5a81ae956f | |||
| 0cac034512 | |||
| 5666995a15 | |||
| 8d3059e4f3 | |||
| a90dc34494 | |||
| 2c6d82593e | |||
| 34bcc2df1a | |||
| c00f2907ea | |||
| b4d528a789 | |||
| d9172cb296 | |||
| bee36cde59 | |||
| d4e7d9d64a | |||
| 7b0265207a | |||
| 7c076579fd | |||
| 7171706d7f | |||
| 9cd46ecbeb | |||
| 5f09ba675d | |||
| 630b926e2a | |||
| 9c6be60ad9 | |||
| a0397fdcf4 | |||
| 59e13e8026 | |||
| 374b51e956 | |||
| 8faa1bf865 | |||
| fc75867218 | |||
| 6d94c2c925 | |||
| eb51dd1379 | |||
| 13a4559c37 | |||
| 4fcf7285d7 | |||
| 0ba9f25155 | |||
| 453c751c7f | |||
| d1eaaef254 | |||
| 3eb466ff4b | |||
| 9f2529c886 | |||
| fb25b28976 | |||
| 612163b82f | |||
| 3c43690a96 | |||
| dd74565c7b | |||
| fb69f67f47 | |||
| 18b48684eb | |||
| 098b0aef6e | |||
| 4ed8171130 | |||
| 335131affc | |||
| bba17a8a67 | |||
| 082df0ec51 | |||
| 1883402b3d | |||
| 88a8b7d2fa | |||
| 987f03c4be | |||
| 1b3aacfa1d | |||
| a03dde8a90 | |||
| 5f04a187ea | |||
| 2b68363452 | |||
| 3a994ab2a4 | |||
| d7713357f4 | |||
| e7c03fdb14 | |||
| 6105956847 | |||
| 89028f175a | |||
| f121098957 | |||
| 4ff32af343 | |||
| 972868c15c | |||
| 0bc57f571b | |||
| 9de5b6f93e | |||
| acf1ded1d4 | |||
| a286f999e2 | |||
| 4b6c1da51d | |||
| a81d5a3d41 | |||
| 4d17111233 | |||
| 64cb9812e0 | |||
| ed037b2e3a | |||
| d2be6a8e3a | |||
| a9667eb0f4 | |||
| 7f3988f3c9 | |||
| 4c095a6f2a | |||
| c10b5c3c8c | |||
| 9d920580a1 | |||
| 34ef4af799 | |||
| 5da47b69dd | |||
| 0e0dd2437b | |||
| e42386b150 | |||
| f21f81022e | |||
| e73a468921 | |||
| c0ac053380 | |||
| 4e670295d1 | |||
| 8d7d8d613c | |||
| 4d632a8679 | |||
| ef219198d4 | |||
| cc744dc581 | |||
| 47006fc9d2 | |||
| ada53362d5 | |||
| a03e48c5ce | |||
| 816b0c7d83 | |||
| a6398f46da | |||
| 56babb2649 | |||
| 0edf4296c4 | |||
| b8fdda50ec | |||
| d25a051eae | |||
| 4a9b788703 | |||
| d4ef321ac2 | |||
| 80c1dbdfbb | |||
| b0af062d74 | |||
| b4e75218f5 | |||
| ab1840dd66 | |||
| 482491e93c | |||
| 2ca991ba3d | |||
| b20c384f5a | |||
| 9ce8edbcd6 | |||
| cb5b2148a3 | |||
| d5702c6282 | |||
| 61a876b582 | |||
| 8c9748e4a0 | |||
| 6460245d5e | |||
| b7979ad48e | |||
| cbd95848e7 | |||
| 4704de937a | |||
| 394d8e99a4 | |||
| a26f25ccd6 | |||
| 94257e0f50 | |||
| b2a42a68a4 | |||
| 7895d59da3 | |||
| b54c60d7af | |||
| 6bab3bf68e | |||
| fdc09c658a | |||
| a690a02f99 | |||
| 0e912fd647 | |||
| 27af330932 | |||
| 7187d28905 | |||
| ca832b6090 | |||
| 53bd6bf06e | |||
| 813f271bdd | |||
| 63dc8fe7dc | |||
| 383f4e4dcf | |||
| 2896652fef | |||
| cfe2648b62 | |||
| 8d49705c87 | |||
| c99e6d8f2c | |||
| 0996bb500c |
@ -1,30 +1,18 @@
|
|||||||
[bumpversion]
|
[bumpversion]
|
||||||
current_version = 2022.5.2
|
current_version = 2022.7.3
|
||||||
tag = True
|
tag = True
|
||||||
commit = True
|
commit = True
|
||||||
parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)\-?(?P<release>.*)
|
parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)
|
||||||
serialize =
|
serialize = {major}.{minor}.{patch}
|
||||||
{major}.{minor}.{patch}-{release}
|
|
||||||
{major}.{minor}.{patch}
|
|
||||||
message = release: {new_version}
|
message = release: {new_version}
|
||||||
tag_name = version/{new_version}
|
tag_name = version/{new_version}
|
||||||
|
|
||||||
[bumpversion:part:release]
|
|
||||||
optional_value = stable
|
|
||||||
first_value = beta
|
|
||||||
values =
|
|
||||||
alpha
|
|
||||||
beta
|
|
||||||
stable
|
|
||||||
|
|
||||||
[bumpversion:file:pyproject.toml]
|
[bumpversion:file:pyproject.toml]
|
||||||
|
|
||||||
[bumpversion:file:docker-compose.yml]
|
[bumpversion:file:docker-compose.yml]
|
||||||
|
|
||||||
[bumpversion:file:schema.yml]
|
[bumpversion:file:schema.yml]
|
||||||
|
|
||||||
[bumpversion:file:.github/workflows/release-publish.yml]
|
|
||||||
|
|
||||||
[bumpversion:file:authentik/__init__.py]
|
[bumpversion:file:authentik/__init__.py]
|
||||||
|
|
||||||
[bumpversion:file:internal/constants/constants.go]
|
[bumpversion:file:internal/constants/constants.go]
|
||||||
|
|||||||
@ -17,6 +17,12 @@ outputs:
|
|||||||
sha:
|
sha:
|
||||||
description: "sha"
|
description: "sha"
|
||||||
value: ${{ steps.ev.outputs.sha }}
|
value: ${{ steps.ev.outputs.sha }}
|
||||||
|
version:
|
||||||
|
description: "version"
|
||||||
|
value: ${{ steps.ev.outputs.version }}
|
||||||
|
versionFamily:
|
||||||
|
description: "versionFamily"
|
||||||
|
value: ${{ steps.ev.outputs.versionFamily }}
|
||||||
|
|
||||||
runs:
|
runs:
|
||||||
using: "composite"
|
using: "composite"
|
||||||
@ -47,3 +53,11 @@ runs:
|
|||||||
print("##[set-output name=timestamp]%s" % int(time()))
|
print("##[set-output name=timestamp]%s" % int(time()))
|
||||||
print("##[set-output name=sha]%s" % os.environ[sha])
|
print("##[set-output name=sha]%s" % os.environ[sha])
|
||||||
print("##[set-output name=shouldBuild]%s" % should_build)
|
print("##[set-output name=shouldBuild]%s" % should_build)
|
||||||
|
|
||||||
|
import configparser
|
||||||
|
parser = configparser.ConfigParser()
|
||||||
|
parser.read(".bumpversion.cfg")
|
||||||
|
version = parser.get("bumpversion", "current_version")
|
||||||
|
version_family = ".".join(version.split(".")[:-1])
|
||||||
|
print("##[set-output name=version]%s" % version)
|
||||||
|
print("##[set-output name=versionFamily]%s" % version_family)
|
||||||
108
.github/dependabot.yml
vendored
108
.github/dependabot.yml
vendored
@ -1,50 +1,62 @@
|
|||||||
version: 2
|
version: 2
|
||||||
updates:
|
updates:
|
||||||
- package-ecosystem: "github-actions"
|
- package-ecosystem: "github-actions"
|
||||||
directory: "/"
|
directory: "/"
|
||||||
schedule:
|
schedule:
|
||||||
interval: daily
|
interval: daily
|
||||||
time: "04:00"
|
time: "04:00"
|
||||||
open-pull-requests-limit: 10
|
open-pull-requests-limit: 10
|
||||||
assignees:
|
reviewers:
|
||||||
- BeryJu
|
- "@goauthentik/core"
|
||||||
- package-ecosystem: gomod
|
commit-message:
|
||||||
directory: "/"
|
prefix: "ci:"
|
||||||
schedule:
|
- package-ecosystem: gomod
|
||||||
interval: daily
|
directory: "/"
|
||||||
time: "04:00"
|
schedule:
|
||||||
open-pull-requests-limit: 10
|
interval: daily
|
||||||
assignees:
|
time: "04:00"
|
||||||
- BeryJu
|
open-pull-requests-limit: 10
|
||||||
- package-ecosystem: npm
|
reviewers:
|
||||||
directory: "/web"
|
- "@goauthentik/core"
|
||||||
schedule:
|
commit-message:
|
||||||
interval: daily
|
prefix: "core:"
|
||||||
time: "04:00"
|
- package-ecosystem: npm
|
||||||
open-pull-requests-limit: 10
|
directory: "/web"
|
||||||
assignees:
|
schedule:
|
||||||
- BeryJu
|
interval: daily
|
||||||
- package-ecosystem: npm
|
time: "04:00"
|
||||||
directory: "/website"
|
open-pull-requests-limit: 10
|
||||||
schedule:
|
reviewers:
|
||||||
interval: daily
|
- "@goauthentik/core"
|
||||||
time: "04:00"
|
commit-message:
|
||||||
open-pull-requests-limit: 10
|
prefix: "web:"
|
||||||
assignees:
|
- package-ecosystem: npm
|
||||||
- BeryJu
|
directory: "/website"
|
||||||
- package-ecosystem: pip
|
schedule:
|
||||||
directory: "/"
|
interval: daily
|
||||||
schedule:
|
time: "04:00"
|
||||||
interval: daily
|
open-pull-requests-limit: 10
|
||||||
time: "04:00"
|
reviewers:
|
||||||
open-pull-requests-limit: 10
|
- "@goauthentik/core"
|
||||||
assignees:
|
commit-message:
|
||||||
- BeryJu
|
prefix: "website:"
|
||||||
- package-ecosystem: docker
|
- package-ecosystem: pip
|
||||||
directory: "/"
|
directory: "/"
|
||||||
schedule:
|
schedule:
|
||||||
interval: daily
|
interval: daily
|
||||||
time: "04:00"
|
time: "04:00"
|
||||||
open-pull-requests-limit: 10
|
open-pull-requests-limit: 10
|
||||||
assignees:
|
reviewers:
|
||||||
- BeryJu
|
- "@goauthentik/core"
|
||||||
|
commit-message:
|
||||||
|
prefix: "core:"
|
||||||
|
- package-ecosystem: docker
|
||||||
|
directory: "/"
|
||||||
|
schedule:
|
||||||
|
interval: daily
|
||||||
|
time: "04:00"
|
||||||
|
open-pull-requests-limit: 10
|
||||||
|
reviewers:
|
||||||
|
- "@goauthentik/core"
|
||||||
|
commit-message:
|
||||||
|
prefix: "core:"
|
||||||
|
|||||||
2
.github/pull_request_template.md
vendored
2
.github/pull_request_template.md
vendored
@ -1,7 +1,7 @@
|
|||||||
<!--
|
<!--
|
||||||
👋 Hello there! Welcome.
|
👋 Hello there! Welcome.
|
||||||
|
|
||||||
Please check the [Contributing guidelines](https://github.com/goauthentik/authentik/blob/master/CONTRIBUTING.md#how-can-i-contribute).
|
Please check the [Contributing guidelines](https://github.com/goauthentik/authentik/blob/main/CONTRIBUTING.md#how-can-i-contribute).
|
||||||
-->
|
-->
|
||||||
|
|
||||||
# Details
|
# Details
|
||||||
|
|||||||
1
.github/stale.yml
vendored
1
.github/stale.yml
vendored
@ -10,6 +10,7 @@ exemptLabels:
|
|||||||
- enhancement
|
- enhancement
|
||||||
- bug/confirmed
|
- bug/confirmed
|
||||||
- enhancement/confirmed
|
- enhancement/confirmed
|
||||||
|
- question
|
||||||
# Comment to post when marking an issue as stale. Set to `false` to disable
|
# Comment to post when marking an issue as stale. Set to `false` to disable
|
||||||
markComment: >
|
markComment: >
|
||||||
This issue has been automatically marked as stale because it has not had
|
This issue has been automatically marked as stale because it has not had
|
||||||
|
|||||||
15
.github/workflows/ci-main.yml
vendored
15
.github/workflows/ci-main.yml
vendored
@ -3,14 +3,14 @@ name: authentik-ci-main
|
|||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- master
|
- main
|
||||||
- next
|
- next
|
||||||
- version-*
|
- version-*
|
||||||
paths-ignore:
|
paths-ignore:
|
||||||
- website
|
- website
|
||||||
pull_request:
|
pull_request:
|
||||||
branches:
|
branches:
|
||||||
- master
|
- main
|
||||||
|
|
||||||
env:
|
env:
|
||||||
POSTGRES_DB: authentik
|
POSTGRES_DB: authentik
|
||||||
@ -106,7 +106,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
domain: ${{github.repository_owner}}
|
domain: ${{github.repository_owner}}
|
||||||
- name: Create k8s Kind Cluster
|
- name: Create k8s Kind Cluster
|
||||||
uses: helm/kind-action@v1.2.0
|
uses: helm/kind-action@v1.3.0
|
||||||
- name: run integration
|
- name: run integration
|
||||||
run: |
|
run: |
|
||||||
poetry run make test-integration
|
poetry run make test-integration
|
||||||
@ -133,12 +133,13 @@ jobs:
|
|||||||
uses: actions/cache@v3
|
uses: actions/cache@v3
|
||||||
with:
|
with:
|
||||||
path: web/dist
|
path: web/dist
|
||||||
key: ${{ runner.os }}-web-${{ hashFiles('web/package-lock.json', 'web/**') }}
|
key: ${{ runner.os }}-web-${{ hashFiles('web/package-lock.json', 'web/src/**') }}
|
||||||
- name: prepare web ui
|
- name: prepare web ui
|
||||||
if: steps.cache-web.outputs.cache-hit != 'true'
|
if: steps.cache-web.outputs.cache-hit != 'true'
|
||||||
working-directory: web
|
working-directory: web
|
||||||
run: |
|
run: |
|
||||||
npm ci
|
npm ci
|
||||||
|
make -C .. gen-client-web
|
||||||
npm run build
|
npm run build
|
||||||
- name: run e2e
|
- name: run e2e
|
||||||
run: |
|
run: |
|
||||||
@ -166,12 +167,13 @@ jobs:
|
|||||||
uses: actions/cache@v3
|
uses: actions/cache@v3
|
||||||
with:
|
with:
|
||||||
path: web/dist
|
path: web/dist
|
||||||
key: ${{ runner.os }}-web-${{ hashFiles('web/package-lock.json', 'web/**') }}
|
key: ${{ runner.os }}-web-${{ hashFiles('web/package-lock.json', 'web/src/**') }}
|
||||||
- name: prepare web ui
|
- name: prepare web ui
|
||||||
if: steps.cache-web.outputs.cache-hit != 'true'
|
if: steps.cache-web.outputs.cache-hit != 'true'
|
||||||
working-directory: web/
|
working-directory: web/
|
||||||
run: |
|
run: |
|
||||||
npm ci
|
npm ci
|
||||||
|
make -C .. gen-client-web
|
||||||
npm run build
|
npm run build
|
||||||
- name: run e2e
|
- name: run e2e
|
||||||
run: |
|
run: |
|
||||||
@ -211,10 +213,10 @@ jobs:
|
|||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v2
|
uses: docker/setup-buildx-action@v2
|
||||||
- name: prepare variables
|
- name: prepare variables
|
||||||
|
uses: ./.github/actions/docker-push-variables
|
||||||
id: ev
|
id: ev
|
||||||
env:
|
env:
|
||||||
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
|
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
|
||||||
uses: ./.github/actions/docker-setup
|
|
||||||
- name: Login to Container Registry
|
- name: Login to Container Registry
|
||||||
uses: docker/login-action@v2
|
uses: docker/login-action@v2
|
||||||
if: ${{ steps.ev.outputs.shouldBuild == 'true' }}
|
if: ${{ steps.ev.outputs.shouldBuild == 'true' }}
|
||||||
@ -231,4 +233,5 @@ jobs:
|
|||||||
ghcr.io/goauthentik/dev-server:gh-${{ steps.ev.outputs.branchNameContainer }}-${{ steps.ev.outputs.timestamp }}-${{ steps.ev.outputs.sha }}
|
ghcr.io/goauthentik/dev-server:gh-${{ steps.ev.outputs.branchNameContainer }}-${{ steps.ev.outputs.timestamp }}-${{ steps.ev.outputs.sha }}
|
||||||
build-args: |
|
build-args: |
|
||||||
GIT_BUILD_HASH=${{ steps.ev.outputs.sha }}
|
GIT_BUILD_HASH=${{ steps.ev.outputs.sha }}
|
||||||
|
VERSION_FAMILY=${{ steps.ev.outputs.versionFamily }}
|
||||||
platforms: ${{ matrix.arch }}
|
platforms: ${{ matrix.arch }}
|
||||||
|
|||||||
9
.github/workflows/ci-outpost.yml
vendored
9
.github/workflows/ci-outpost.yml
vendored
@ -3,12 +3,12 @@ name: authentik-ci-outpost
|
|||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- master
|
- main
|
||||||
- next
|
- next
|
||||||
- version-*
|
- version-*
|
||||||
pull_request:
|
pull_request:
|
||||||
branches:
|
branches:
|
||||||
- master
|
- main
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
lint-golint:
|
lint-golint:
|
||||||
@ -67,8 +67,8 @@ jobs:
|
|||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v2
|
uses: docker/setup-buildx-action@v2
|
||||||
- name: prepare variables
|
- name: prepare variables
|
||||||
|
uses: ./.github/actions/docker-push-variables
|
||||||
id: ev
|
id: ev
|
||||||
uses: ./.github/actions/docker-setup
|
|
||||||
env:
|
env:
|
||||||
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
|
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
|
||||||
- name: Login to Container Registry
|
- name: Login to Container Registry
|
||||||
@ -91,6 +91,7 @@ jobs:
|
|||||||
file: ${{ matrix.type }}.Dockerfile
|
file: ${{ matrix.type }}.Dockerfile
|
||||||
build-args: |
|
build-args: |
|
||||||
GIT_BUILD_HASH=${{ steps.ev.outputs.sha }}
|
GIT_BUILD_HASH=${{ steps.ev.outputs.sha }}
|
||||||
|
VERSION_FAMILY=${{ steps.ev.outputs.versionFamily }}
|
||||||
platforms: ${{ matrix.arch }}
|
platforms: ${{ matrix.arch }}
|
||||||
build-outpost-binary:
|
build-outpost-binary:
|
||||||
timeout-minutes: 120
|
timeout-minutes: 120
|
||||||
@ -110,7 +111,7 @@ jobs:
|
|||||||
- uses: actions/setup-go@v3
|
- uses: actions/setup-go@v3
|
||||||
with:
|
with:
|
||||||
go-version: "^1.17"
|
go-version: "^1.17"
|
||||||
- uses: actions/setup-node@v3.2.0
|
- uses: actions/setup-node@v3.4.1
|
||||||
with:
|
with:
|
||||||
node-version: '16'
|
node-version: '16'
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
|
|||||||
19
.github/workflows/ci-web.yml
vendored
19
.github/workflows/ci-web.yml
vendored
@ -3,19 +3,19 @@ name: authentik-ci-web
|
|||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- master
|
- main
|
||||||
- next
|
- next
|
||||||
- version-*
|
- version-*
|
||||||
pull_request:
|
pull_request:
|
||||||
branches:
|
branches:
|
||||||
- master
|
- main
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
lint-eslint:
|
lint-eslint:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v3
|
||||||
- uses: actions/setup-node@v3.2.0
|
- uses: actions/setup-node@v3.4.1
|
||||||
with:
|
with:
|
||||||
node-version: '16'
|
node-version: '16'
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
@ -31,7 +31,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v3
|
||||||
- uses: actions/setup-node@v3.2.0
|
- uses: actions/setup-node@v3.4.1
|
||||||
with:
|
with:
|
||||||
node-version: '16'
|
node-version: '16'
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
@ -47,13 +47,18 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v3
|
||||||
- uses: actions/setup-node@v3.2.0
|
- uses: actions/setup-node@v3.4.1
|
||||||
with:
|
with:
|
||||||
node-version: '16'
|
node-version: '16'
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
cache-dependency-path: web/package-lock.json
|
cache-dependency-path: web/package-lock.json
|
||||||
- working-directory: web/
|
- working-directory: web/
|
||||||
run: npm ci
|
run: |
|
||||||
|
npm ci
|
||||||
|
# lit-analyse doesn't understand path rewrites, so make it
|
||||||
|
# belive it's an actual module
|
||||||
|
cd node_modules/@goauthentik
|
||||||
|
ln -s ../../src/ web
|
||||||
- name: Generate API
|
- name: Generate API
|
||||||
run: make gen-client-web
|
run: make gen-client-web
|
||||||
- name: lit-analyse
|
- name: lit-analyse
|
||||||
@ -73,7 +78,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v3
|
||||||
- uses: actions/setup-node@v3.2.0
|
- uses: actions/setup-node@v3.4.1
|
||||||
with:
|
with:
|
||||||
node-version: '16'
|
node-version: '16'
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
|
|||||||
6
.github/workflows/ci-website.yml
vendored
6
.github/workflows/ci-website.yml
vendored
@ -3,19 +3,19 @@ name: authentik-ci-website
|
|||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- master
|
- main
|
||||||
- next
|
- next
|
||||||
- version-*
|
- version-*
|
||||||
pull_request:
|
pull_request:
|
||||||
branches:
|
branches:
|
||||||
- master
|
- main
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
lint-prettier:
|
lint-prettier:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v3
|
||||||
- uses: actions/setup-node@v3.2.0
|
- uses: actions/setup-node@v3.4.1
|
||||||
with:
|
with:
|
||||||
node-version: '16'
|
node-version: '16'
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
|
|||||||
4
.github/workflows/codeql-analysis.yml
vendored
4
.github/workflows/codeql-analysis.yml
vendored
@ -2,10 +2,10 @@ name: "CodeQL"
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [ master, '*', next, version* ]
|
branches: [ main, '*', next, version* ]
|
||||||
pull_request:
|
pull_request:
|
||||||
# The branches below must be a subset of the branches above
|
# The branches below must be a subset of the branches above
|
||||||
branches: [ master ]
|
branches: [ main ]
|
||||||
schedule:
|
schedule:
|
||||||
- cron: '30 6 * * 5'
|
- cron: '30 6 * * 5'
|
||||||
|
|
||||||
|
|||||||
2
.github/workflows/ghcr-retention.yml
vendored
2
.github/workflows/ghcr-retention.yml
vendored
@ -19,4 +19,4 @@ jobs:
|
|||||||
org-name: goauthentik
|
org-name: goauthentik
|
||||||
untagged-only: false
|
untagged-only: false
|
||||||
token: ${{ secrets.GHCR_CLEANUP_TOKEN }}
|
token: ${{ secrets.GHCR_CLEANUP_TOKEN }}
|
||||||
skip-tags: gh-next,gh-master
|
skip-tags: gh-next,gh-main
|
||||||
|
|||||||
26
.github/workflows/release-publish.yml
vendored
26
.github/workflows/release-publish.yml
vendored
@ -5,7 +5,6 @@ on:
|
|||||||
types: [published, created]
|
types: [published, created]
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
# Build
|
|
||||||
build-server:
|
build-server:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
@ -14,6 +13,9 @@ jobs:
|
|||||||
uses: docker/setup-qemu-action@v2.0.0
|
uses: docker/setup-qemu-action@v2.0.0
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v2
|
uses: docker/setup-buildx-action@v2
|
||||||
|
- name: prepare variables
|
||||||
|
uses: ./.github/actions/docker-push-variables
|
||||||
|
id: ev
|
||||||
- name: Docker Login Registry
|
- name: Docker Login Registry
|
||||||
uses: docker/login-action@v2
|
uses: docker/login-action@v2
|
||||||
with:
|
with:
|
||||||
@ -30,9 +32,11 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
push: ${{ github.event_name == 'release' }}
|
push: ${{ github.event_name == 'release' }}
|
||||||
tags: |
|
tags: |
|
||||||
beryju/authentik:2022.5.2,
|
beryju/authentik:${{ steps.ev.outputs.version }},
|
||||||
|
beryju/authentik:${{ steps.ev.outputs.versionFamily }},
|
||||||
beryju/authentik:latest,
|
beryju/authentik:latest,
|
||||||
ghcr.io/goauthentik/server:2022.5.2,
|
ghcr.io/goauthentik/server:${{ steps.ev.outputs.version }},
|
||||||
|
ghcr.io/goauthentik/server:${{ steps.ev.outputs.versionFamily }},
|
||||||
ghcr.io/goauthentik/server:latest
|
ghcr.io/goauthentik/server:latest
|
||||||
platforms: linux/amd64,linux/arm64
|
platforms: linux/amd64,linux/arm64
|
||||||
context: .
|
context: .
|
||||||
@ -53,6 +57,9 @@ jobs:
|
|||||||
uses: docker/setup-qemu-action@v2.0.0
|
uses: docker/setup-qemu-action@v2.0.0
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v2
|
uses: docker/setup-buildx-action@v2
|
||||||
|
- name: prepare variables
|
||||||
|
uses: ./.github/actions/docker-push-variables
|
||||||
|
id: ev
|
||||||
- name: Docker Login Registry
|
- name: Docker Login Registry
|
||||||
uses: docker/login-action@v2
|
uses: docker/login-action@v2
|
||||||
with:
|
with:
|
||||||
@ -69,9 +76,11 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
push: ${{ github.event_name == 'release' }}
|
push: ${{ github.event_name == 'release' }}
|
||||||
tags: |
|
tags: |
|
||||||
beryju/authentik-${{ matrix.type }}:2022.5.2,
|
beryju/authentik-${{ matrix.type }}:${{ steps.ev.outputs.version }},
|
||||||
|
beryju/authentik-${{ matrix.type }}:${{ steps.ev.outputs.versionFamily }},
|
||||||
beryju/authentik-${{ matrix.type }}:latest,
|
beryju/authentik-${{ matrix.type }}:latest,
|
||||||
ghcr.io/goauthentik/${{ matrix.type }}:2022.5.2,
|
ghcr.io/goauthentik/${{ matrix.type }}:${{ steps.ev.outputs.version }},
|
||||||
|
ghcr.io/goauthentik/${{ matrix.type }}:${{ steps.ev.outputs.versionFamily }},
|
||||||
ghcr.io/goauthentik/${{ matrix.type }}:latest
|
ghcr.io/goauthentik/${{ matrix.type }}:latest
|
||||||
file: ${{ matrix.type }}.Dockerfile
|
file: ${{ matrix.type }}.Dockerfile
|
||||||
platforms: linux/amd64,linux/arm64
|
platforms: linux/amd64,linux/arm64
|
||||||
@ -91,7 +100,7 @@ jobs:
|
|||||||
- uses: actions/setup-go@v3
|
- uses: actions/setup-go@v3
|
||||||
with:
|
with:
|
||||||
go-version: "^1.17"
|
go-version: "^1.17"
|
||||||
- uses: actions/setup-node@v3.2.0
|
- uses: actions/setup-node@v3.4.1
|
||||||
with:
|
with:
|
||||||
node-version: '16'
|
node-version: '16'
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
@ -138,6 +147,9 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v3
|
||||||
|
- name: prepare variables
|
||||||
|
uses: ./.github/actions/docker-push-variables
|
||||||
|
id: ev
|
||||||
- name: Get static files from docker image
|
- name: Get static files from docker image
|
||||||
run: |
|
run: |
|
||||||
docker pull ghcr.io/goauthentik/server:latest
|
docker pull ghcr.io/goauthentik/server:latest
|
||||||
@ -152,7 +164,7 @@ jobs:
|
|||||||
SENTRY_PROJECT: authentik
|
SENTRY_PROJECT: authentik
|
||||||
SENTRY_URL: https://sentry.beryju.org
|
SENTRY_URL: https://sentry.beryju.org
|
||||||
with:
|
with:
|
||||||
version: authentik@2022.5.2
|
version: authentik@${{ steps.ev.outputs.version }}
|
||||||
environment: beryjuorg-prod
|
environment: beryjuorg-prod
|
||||||
sourcemaps: './web/dist'
|
sourcemaps: './web/dist'
|
||||||
url_prefix: '~/static/dist'
|
url_prefix: '~/static/dist'
|
||||||
|
|||||||
2
.github/workflows/translation-compile.yml
vendored
2
.github/workflows/translation-compile.yml
vendored
@ -1,7 +1,7 @@
|
|||||||
name: authentik-backend-translate-compile
|
name: authentik-backend-translate-compile
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [ master ]
|
branches: [ main ]
|
||||||
paths:
|
paths:
|
||||||
- '/locale/'
|
- '/locale/'
|
||||||
pull_request:
|
pull_request:
|
||||||
|
|||||||
4
.github/workflows/web-api-publish.yml
vendored
4
.github/workflows/web-api-publish.yml
vendored
@ -1,7 +1,7 @@
|
|||||||
name: authentik-web-api-publish
|
name: authentik-web-api-publish
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [ master ]
|
branches: [ main ]
|
||||||
paths:
|
paths:
|
||||||
- 'schema.yml'
|
- 'schema.yml'
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
@ -11,7 +11,7 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v3
|
||||||
# Setup .npmrc file to publish to npm
|
# Setup .npmrc file to publish to npm
|
||||||
- uses: actions/setup-node@v3.2.0
|
- uses: actions/setup-node@v3.4.1
|
||||||
with:
|
with:
|
||||||
node-version: '16'
|
node-version: '16'
|
||||||
registry-url: 'https://registry.npmjs.org'
|
registry-url: 'https://registry.npmjs.org'
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@ -193,7 +193,6 @@ pip-selfcheck.json
|
|||||||
# End of https://www.gitignore.io/api/python,django
|
# End of https://www.gitignore.io/api/python,django
|
||||||
/static/
|
/static/
|
||||||
local.env.yml
|
local.env.yml
|
||||||
.vscode/
|
|
||||||
|
|
||||||
# Selenium Screenshots
|
# Selenium Screenshots
|
||||||
selenium_screenshots/
|
selenium_screenshots/
|
||||||
|
|||||||
7
.vscode/settings.json
vendored
7
.vscode/settings.json
vendored
@ -1,5 +1,6 @@
|
|||||||
{
|
{
|
||||||
"cSpell.words": [
|
"cSpell.words": [
|
||||||
|
"akadmin",
|
||||||
"asgi",
|
"asgi",
|
||||||
"authentik",
|
"authentik",
|
||||||
"authn",
|
"authn",
|
||||||
@ -21,5 +22,9 @@
|
|||||||
"python.formatting.provider": "black",
|
"python.formatting.provider": "black",
|
||||||
"files.associations": {
|
"files.associations": {
|
||||||
"*.akflow": "json"
|
"*.akflow": "json"
|
||||||
}
|
},
|
||||||
|
"typescript.preferences.importModuleSpecifier": "non-relative",
|
||||||
|
"typescript.preferences.importModuleSpecifierEnding": "index",
|
||||||
|
"typescript.tsdk": "./web/node_modules/typescript/lib",
|
||||||
|
"typescript.enablePromptUseWorkspaceTsdk": true
|
||||||
}
|
}
|
||||||
|
|||||||
86
.vscode/tasks.json
vendored
Normal file
86
.vscode/tasks.json
vendored
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
{
|
||||||
|
"version": "2.0.0",
|
||||||
|
"tasks": [
|
||||||
|
{
|
||||||
|
"label": "authentik[core]: format & test",
|
||||||
|
"command": "poetry",
|
||||||
|
"args": [
|
||||||
|
"run",
|
||||||
|
"make"
|
||||||
|
],
|
||||||
|
"group": "build",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "authentik[core]: run",
|
||||||
|
"command": "poetry",
|
||||||
|
"args": [
|
||||||
|
"run",
|
||||||
|
"make",
|
||||||
|
"run",
|
||||||
|
],
|
||||||
|
"group": "build",
|
||||||
|
"presentation": {
|
||||||
|
"panel": "dedicated",
|
||||||
|
"group": "running"
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "authentik[web]: format",
|
||||||
|
"command": "make",
|
||||||
|
"args": ["web"],
|
||||||
|
"group": "build",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "authentik[web]: watch",
|
||||||
|
"command": "make",
|
||||||
|
"args": ["web-watch"],
|
||||||
|
"group": "build",
|
||||||
|
"presentation": {
|
||||||
|
"panel": "dedicated",
|
||||||
|
"group": "running"
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "authentik: install",
|
||||||
|
"command": "make",
|
||||||
|
"args": ["install"],
|
||||||
|
"group": "build",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "authentik: i18n-extract",
|
||||||
|
"command": "poetry",
|
||||||
|
"args": [
|
||||||
|
"run",
|
||||||
|
"make",
|
||||||
|
"i18n-extract"
|
||||||
|
],
|
||||||
|
"group": "build",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "authentik[website]: format",
|
||||||
|
"command": "make",
|
||||||
|
"args": ["website"],
|
||||||
|
"group": "build",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "authentik[website]: watch",
|
||||||
|
"command": "make",
|
||||||
|
"args": ["website-watch"],
|
||||||
|
"group": "build",
|
||||||
|
"presentation": {
|
||||||
|
"panel": "dedicated",
|
||||||
|
"group": "running"
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "authentik[api]: generate",
|
||||||
|
"command": "poetry",
|
||||||
|
"args": [
|
||||||
|
"run",
|
||||||
|
"make",
|
||||||
|
"gen"
|
||||||
|
],
|
||||||
|
"group": "build"
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
@ -60,7 +60,7 @@ representative at an online or offline event.
|
|||||||
|
|
||||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
||||||
reported to the community leaders responsible for enforcement at
|
reported to the community leaders responsible for enforcement at
|
||||||
hello@beryju.org.
|
hello@goauthentik.io.
|
||||||
All complaints will be reviewed and investigated promptly and fairly.
|
All complaints will be reviewed and investigated promptly and fairly.
|
||||||
|
|
||||||
All community leaders are obligated to respect the privacy and security of the
|
All community leaders are obligated to respect the privacy and security of the
|
||||||
|
|||||||
@ -18,7 +18,7 @@ WORKDIR /work/web
|
|||||||
RUN npm ci && npm run build
|
RUN npm ci && npm run build
|
||||||
|
|
||||||
# Stage 3: Poetry to requirements.txt export
|
# Stage 3: Poetry to requirements.txt export
|
||||||
FROM docker.io/python:3.10.4-slim-bullseye AS poetry-locker
|
FROM docker.io/python:3.10.5-slim-bullseye AS poetry-locker
|
||||||
|
|
||||||
WORKDIR /work
|
WORKDIR /work
|
||||||
COPY ./pyproject.toml /work
|
COPY ./pyproject.toml /work
|
||||||
@ -29,7 +29,7 @@ RUN pip install --no-cache-dir poetry && \
|
|||||||
poetry export -f requirements.txt --dev --output requirements-dev.txt
|
poetry export -f requirements.txt --dev --output requirements-dev.txt
|
||||||
|
|
||||||
# Stage 4: Build go proxy
|
# Stage 4: Build go proxy
|
||||||
FROM docker.io/golang:1.18.2-bullseye AS builder
|
FROM docker.io/golang:1.18.4-bullseye AS builder
|
||||||
|
|
||||||
WORKDIR /work
|
WORKDIR /work
|
||||||
|
|
||||||
@ -45,7 +45,7 @@ COPY ./go.sum /work/go.sum
|
|||||||
RUN go build -o /work/authentik ./cmd/server/main.go
|
RUN go build -o /work/authentik ./cmd/server/main.go
|
||||||
|
|
||||||
# Stage 5: Run
|
# Stage 5: Run
|
||||||
FROM docker.io/python:3.10.4-slim-bullseye
|
FROM docker.io/python:3.10.5-slim-bullseye
|
||||||
|
|
||||||
LABEL org.opencontainers.image.url https://goauthentik.io
|
LABEL org.opencontainers.image.url https://goauthentik.io
|
||||||
LABEL org.opencontainers.image.description goauthentik.io Main server image, see https://goauthentik.io for more info.
|
LABEL org.opencontainers.image.description goauthentik.io Main server image, see https://goauthentik.io for more info.
|
||||||
|
|||||||
21
Makefile
21
Makefile
@ -45,8 +45,8 @@ lint-fix:
|
|||||||
website/developer-docs
|
website/developer-docs
|
||||||
|
|
||||||
lint:
|
lint:
|
||||||
bandit -r authentik tests lifecycle -x node_modules
|
|
||||||
pylint authentik tests lifecycle
|
pylint authentik tests lifecycle
|
||||||
|
bandit -r authentik tests lifecycle -x node_modules
|
||||||
golangci-lint run -v
|
golangci-lint run -v
|
||||||
|
|
||||||
i18n-extract: i18n-extract-core web-extract
|
i18n-extract: i18n-extract-core web-extract
|
||||||
@ -55,7 +55,7 @@ i18n-extract-core:
|
|||||||
./manage.py makemessages --ignore web --ignore internal --ignore web --ignore web-api --ignore website -l en
|
./manage.py makemessages --ignore web --ignore internal --ignore web --ignore web-api --ignore website -l en
|
||||||
|
|
||||||
gen-build:
|
gen-build:
|
||||||
./manage.py spectacular --file schema.yml
|
AUTHENTIK_DEBUG=true ./manage.py spectacular --file schema.yml
|
||||||
|
|
||||||
gen-clean:
|
gen-clean:
|
||||||
rm -rf web/api/src/
|
rm -rf web/api/src/
|
||||||
@ -65,7 +65,7 @@ gen-client-web:
|
|||||||
docker run \
|
docker run \
|
||||||
--rm -v ${PWD}:/local \
|
--rm -v ${PWD}:/local \
|
||||||
--user ${UID}:${GID} \
|
--user ${UID}:${GID} \
|
||||||
openapitools/openapi-generator-cli:v6.0.0-beta generate \
|
openapitools/openapi-generator-cli:v6.0.0 generate \
|
||||||
-i /local/schema.yml \
|
-i /local/schema.yml \
|
||||||
-g typescript-fetch \
|
-g typescript-fetch \
|
||||||
-o /local/gen-ts-api \
|
-o /local/gen-ts-api \
|
||||||
@ -83,7 +83,7 @@ gen-client-go:
|
|||||||
docker run \
|
docker run \
|
||||||
--rm -v ${PWD}:/local \
|
--rm -v ${PWD}:/local \
|
||||||
--user ${UID}:${GID} \
|
--user ${UID}:${GID} \
|
||||||
openapitools/openapi-generator-cli:v5.2.1 generate \
|
openapitools/openapi-generator-cli:v6.0.0 generate \
|
||||||
-i /local/schema.yml \
|
-i /local/schema.yml \
|
||||||
-g go \
|
-g go \
|
||||||
-o /local/gen-go-api \
|
-o /local/gen-go-api \
|
||||||
@ -103,12 +103,18 @@ run:
|
|||||||
## Web
|
## Web
|
||||||
#########################
|
#########################
|
||||||
|
|
||||||
web: web-lint-fix web-lint web-extract
|
web-build: web-install
|
||||||
|
cd web && npm run build
|
||||||
|
|
||||||
|
web: web-lint-fix web-lint
|
||||||
|
|
||||||
web-install:
|
web-install:
|
||||||
cd web && npm ci
|
cd web && npm ci
|
||||||
|
|
||||||
web-watch:
|
web-watch:
|
||||||
|
rm -rf web/dist/
|
||||||
|
mkdir web/dist/
|
||||||
|
touch web/dist/.gitkeep
|
||||||
cd web && npm run watch
|
cd web && npm run watch
|
||||||
|
|
||||||
web-lint-fix:
|
web-lint-fix:
|
||||||
@ -163,8 +169,3 @@ ci-pending-migrations: ci--meta-debug
|
|||||||
|
|
||||||
install: web-install website-install
|
install: web-install website-install
|
||||||
poetry install
|
poetry install
|
||||||
|
|
||||||
a: install
|
|
||||||
tmux \
|
|
||||||
new-session 'make run' \; \
|
|
||||||
split-window 'make web-watch'
|
|
||||||
|
|||||||
@ -9,7 +9,7 @@
|
|||||||
[](https://github.com/goauthentik/authentik/actions/workflows/ci-outpost.yml)
|
[](https://github.com/goauthentik/authentik/actions/workflows/ci-outpost.yml)
|
||||||
[](https://github.com/goauthentik/authentik/actions/workflows/ci-web.yml)
|
[](https://github.com/goauthentik/authentik/actions/workflows/ci-web.yml)
|
||||||
[](https://codecov.io/gh/goauthentik/authentik)
|
[](https://codecov.io/gh/goauthentik/authentik)
|
||||||
[](https://goauthentik.testspace.com/)
|
[](https://goauthentik.testspace.com/)
|
||||||

|

|
||||||

|

|
||||||
[](https://www.transifex.com/beryjuorg/authentik/)
|
[](https://www.transifex.com/beryjuorg/authentik/)
|
||||||
|
|||||||
@ -6,9 +6,10 @@
|
|||||||
|
|
||||||
| Version | Supported |
|
| Version | Supported |
|
||||||
| ---------- | ------------------ |
|
| ---------- | ------------------ |
|
||||||
| 2022.3.x | :white_check_mark: |
|
| 2022.5.x | :white_check_mark: |
|
||||||
| 2022.4.x | :white_check_mark: |
|
| 2022.6.x | :white_check_mark: |
|
||||||
|
| 2022.7.x | :white_check_mark: |
|
||||||
|
|
||||||
## Reporting a Vulnerability
|
## Reporting a Vulnerability
|
||||||
|
|
||||||
To report a vulnerability, send an email to [security@beryju.org](mailto:security@beryju.org)
|
To report a vulnerability, send an email to [security@goauthentik.io](mailto:security@goauthentik.io)
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
from os import environ
|
from os import environ
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
__version__ = "2022.5.2"
|
__version__ = "2022.7.3"
|
||||||
ENV_GIT_HASH_KEY = "GIT_BUILD_HASH"
|
ENV_GIT_HASH_KEY = "GIT_BUILD_HASH"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -1,7 +1,6 @@
|
|||||||
"""authentik administration overview"""
|
"""authentik administration overview"""
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from drf_spectacular.utils import extend_schema, inline_serializer
|
from drf_spectacular.utils import extend_schema, inline_serializer
|
||||||
from prometheus_client import Gauge
|
|
||||||
from rest_framework.fields import IntegerField
|
from rest_framework.fields import IntegerField
|
||||||
from rest_framework.permissions import IsAdminUser
|
from rest_framework.permissions import IsAdminUser
|
||||||
from rest_framework.request import Request
|
from rest_framework.request import Request
|
||||||
@ -10,8 +9,6 @@ from rest_framework.views import APIView
|
|||||||
|
|
||||||
from authentik.root.celery import CELERY_APP
|
from authentik.root.celery import CELERY_APP
|
||||||
|
|
||||||
GAUGE_WORKERS = Gauge("authentik_admin_workers", "Currently connected workers")
|
|
||||||
|
|
||||||
|
|
||||||
class WorkerView(APIView):
|
class WorkerView(APIView):
|
||||||
"""Get currently connected worker count."""
|
"""Get currently connected worker count."""
|
||||||
|
|||||||
@ -2,6 +2,10 @@
|
|||||||
from importlib import import_module
|
from importlib import import_module
|
||||||
|
|
||||||
from django.apps import AppConfig
|
from django.apps import AppConfig
|
||||||
|
from prometheus_client import Gauge, Info
|
||||||
|
|
||||||
|
PROM_INFO = Info("authentik_version", "Currently running authentik version")
|
||||||
|
GAUGE_WORKERS = Gauge("authentik_admin_workers", "Currently connected workers")
|
||||||
|
|
||||||
|
|
||||||
class AuthentikAdminConfig(AppConfig):
|
class AuthentikAdminConfig(AppConfig):
|
||||||
@ -12,7 +16,4 @@ class AuthentikAdminConfig(AppConfig):
|
|||||||
verbose_name = "authentik Admin"
|
verbose_name = "authentik Admin"
|
||||||
|
|
||||||
def ready(self):
|
def ready(self):
|
||||||
from authentik.admin.tasks import clear_update_notifications
|
|
||||||
|
|
||||||
clear_update_notifications.delay()
|
|
||||||
import_module("authentik.admin.signals")
|
import_module("authentik.admin.signals")
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
from django.dispatch import receiver
|
from django.dispatch import receiver
|
||||||
|
|
||||||
from authentik.admin.api.tasks import TaskInfo
|
from authentik.admin.api.tasks import TaskInfo
|
||||||
from authentik.admin.api.workers import GAUGE_WORKERS
|
from authentik.admin.apps import GAUGE_WORKERS
|
||||||
from authentik.root.celery import CELERY_APP
|
from authentik.root.celery import CELERY_APP
|
||||||
from authentik.root.monitoring import monitoring_set
|
from authentik.root.monitoring import monitoring_set
|
||||||
|
|
||||||
|
|||||||
@ -4,11 +4,11 @@ import re
|
|||||||
from django.core.cache import cache
|
from django.core.cache import cache
|
||||||
from django.core.validators import URLValidator
|
from django.core.validators import URLValidator
|
||||||
from packaging.version import parse
|
from packaging.version import parse
|
||||||
from prometheus_client import Info
|
|
||||||
from requests import RequestException
|
from requests import RequestException
|
||||||
from structlog.stdlib import get_logger
|
from structlog.stdlib import get_logger
|
||||||
|
|
||||||
from authentik import __version__, get_build_hash
|
from authentik import __version__, get_build_hash
|
||||||
|
from authentik.admin.apps import PROM_INFO
|
||||||
from authentik.events.models import Event, EventAction, Notification
|
from authentik.events.models import Event, EventAction, Notification
|
||||||
from authentik.events.monitored_tasks import (
|
from authentik.events.monitored_tasks import (
|
||||||
MonitoredTask,
|
MonitoredTask,
|
||||||
@ -25,7 +25,6 @@ VERSION_CACHE_KEY = "authentik_latest_version"
|
|||||||
VERSION_CACHE_TIMEOUT = 8 * 60 * 60 # 8 hours
|
VERSION_CACHE_TIMEOUT = 8 * 60 * 60 # 8 hours
|
||||||
# Chop of the first ^ because we want to search the entire string
|
# Chop of the first ^ because we want to search the entire string
|
||||||
URL_FINDER = URLValidator.regex.pattern[1:]
|
URL_FINDER = URLValidator.regex.pattern[1:]
|
||||||
PROM_INFO = Info("authentik_version", "Currently running authentik version")
|
|
||||||
LOCAL_VERSION = parse(__version__)
|
LOCAL_VERSION = parse(__version__)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -10,6 +10,8 @@ from structlog.stdlib import get_logger
|
|||||||
from authentik.core.middleware import KEY_AUTH_VIA, LOCAL
|
from authentik.core.middleware import KEY_AUTH_VIA, LOCAL
|
||||||
from authentik.core.models import Token, TokenIntents, User
|
from authentik.core.models import Token, TokenIntents, User
|
||||||
from authentik.outposts.models import Outpost
|
from authentik.outposts.models import Outpost
|
||||||
|
from authentik.providers.oauth2.constants import SCOPE_AUTHENTIK_API
|
||||||
|
from authentik.providers.oauth2.models import RefreshToken
|
||||||
|
|
||||||
LOGGER = get_logger()
|
LOGGER = get_logger()
|
||||||
|
|
||||||
@ -24,7 +26,7 @@ def validate_auth(header: bytes) -> str:
|
|||||||
if auth_type.lower() != "bearer":
|
if auth_type.lower() != "bearer":
|
||||||
LOGGER.debug("Unsupported authentication type, denying", type=auth_type.lower())
|
LOGGER.debug("Unsupported authentication type, denying", type=auth_type.lower())
|
||||||
raise AuthenticationFailed("Unsupported authentication type")
|
raise AuthenticationFailed("Unsupported authentication type")
|
||||||
if auth_credentials == "": # nosec
|
if auth_credentials == "": # nosec # noqa
|
||||||
raise AuthenticationFailed("Malformed header")
|
raise AuthenticationFailed("Malformed header")
|
||||||
return auth_credentials
|
return auth_credentials
|
||||||
|
|
||||||
@ -34,14 +36,30 @@ def bearer_auth(raw_header: bytes) -> Optional[User]:
|
|||||||
auth_credentials = validate_auth(raw_header)
|
auth_credentials = validate_auth(raw_header)
|
||||||
if not auth_credentials:
|
if not auth_credentials:
|
||||||
return None
|
return None
|
||||||
|
if not hasattr(LOCAL, "authentik"):
|
||||||
|
LOCAL.authentik = {}
|
||||||
# first, check traditional tokens
|
# first, check traditional tokens
|
||||||
token = Token.filter_not_expired(key=auth_credentials, intent=TokenIntents.INTENT_API).first()
|
key_token = Token.filter_not_expired(
|
||||||
if hasattr(LOCAL, "authentik"):
|
key=auth_credentials, intent=TokenIntents.INTENT_API
|
||||||
|
).first()
|
||||||
|
if key_token:
|
||||||
LOCAL.authentik[KEY_AUTH_VIA] = "api_token"
|
LOCAL.authentik[KEY_AUTH_VIA] = "api_token"
|
||||||
if token:
|
return key_token.user
|
||||||
return token.user
|
# then try to auth via JWT
|
||||||
|
jwt_token = RefreshToken.filter_not_expired(
|
||||||
|
refresh_token=auth_credentials, _scope__icontains=SCOPE_AUTHENTIK_API
|
||||||
|
).first()
|
||||||
|
if jwt_token:
|
||||||
|
# Double-check scopes, since they are saved in a single string
|
||||||
|
# we want to check the parsed version too
|
||||||
|
if SCOPE_AUTHENTIK_API not in jwt_token.scope:
|
||||||
|
raise AuthenticationFailed("Token invalid/expired")
|
||||||
|
LOCAL.authentik[KEY_AUTH_VIA] = "jwt"
|
||||||
|
return jwt_token.user
|
||||||
|
# then try to auth via secret key (for embedded outpost/etc)
|
||||||
user = token_secret_key(auth_credentials)
|
user = token_secret_key(auth_credentials)
|
||||||
if user:
|
if user:
|
||||||
|
LOCAL.authentik[KEY_AUTH_VIA] = "secret_key"
|
||||||
return user
|
return user
|
||||||
raise AuthenticationFailed("Token invalid/expired")
|
raise AuthenticationFailed("Token invalid/expired")
|
||||||
|
|
||||||
@ -56,8 +74,6 @@ def token_secret_key(value: str) -> Optional[User]:
|
|||||||
outposts = Outpost.objects.filter(managed=MANAGED_OUTPOST)
|
outposts = Outpost.objects.filter(managed=MANAGED_OUTPOST)
|
||||||
if not outposts:
|
if not outposts:
|
||||||
return None
|
return None
|
||||||
if hasattr(LOCAL, "authentik"):
|
|
||||||
LOCAL.authentik[KEY_AUTH_VIA] = "secret_key"
|
|
||||||
outpost = outposts.first()
|
outpost = outposts.first()
|
||||||
return outpost.user
|
return outpost.user
|
||||||
|
|
||||||
|
|||||||
@ -8,9 +8,6 @@ API Browser - {{ tenant.branding_title }}
|
|||||||
|
|
||||||
{% block head %}
|
{% block head %}
|
||||||
<script type="module" src="{% static 'dist/rapidoc-min.js' %}"></script>
|
<script type="module" src="{% static 'dist/rapidoc-min.js' %}"></script>
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block body %}
|
|
||||||
<script>
|
<script>
|
||||||
function getCookie(name) {
|
function getCookie(name) {
|
||||||
let cookieValue = "";
|
let cookieValue = "";
|
||||||
@ -34,16 +31,58 @@ window.addEventListener('DOMContentLoaded', (event) => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
<style>
|
||||||
|
img.logo {
|
||||||
|
width: 100%;
|
||||||
|
padding: 1rem 0.5rem 1.5rem 0.5rem;
|
||||||
|
min-height: 48px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block body %}
|
||||||
<rapi-doc
|
<rapi-doc
|
||||||
spec-url="{{ path }}"
|
spec-url="{{ path }}"
|
||||||
heading-text="authentik"
|
heading-text=""
|
||||||
theme="dark"
|
theme="light"
|
||||||
render-style="view"
|
render-style="read"
|
||||||
|
default-schema-tab="schema"
|
||||||
primary-color="#fd4b2d"
|
primary-color="#fd4b2d"
|
||||||
|
nav-bg-color="#212427"
|
||||||
|
bg-color="#000000"
|
||||||
|
text-color="#000000"
|
||||||
|
nav-text-color="#ffffff"
|
||||||
|
nav-hover-bg-color="#3c3f42"
|
||||||
|
nav-accent-color="#4f5255"
|
||||||
|
nav-hover-text-color="#ffffff"
|
||||||
|
use-path-in-nav-bar="true"
|
||||||
|
nav-item-spacing="relaxed"
|
||||||
|
allow-server-selection="false"
|
||||||
|
show-header="false"
|
||||||
allow-spec-url-load="false"
|
allow-spec-url-load="false"
|
||||||
allow-spec-file-load="false">
|
allow-spec-file-load="false">
|
||||||
<div slot="logo">
|
<div slot="nav-logo">
|
||||||
<img src="{% static 'dist/assets/icons/icon.png' %}" style="width:50px; height:50px" />
|
<img class="logo" src="{% static 'dist/assets/icons/icon_left_brand.png' %}" />
|
||||||
</div>
|
</div>
|
||||||
</rapi-doc>
|
</rapi-doc>
|
||||||
|
<script>
|
||||||
|
const rapidoc = document.querySelector("rapi-doc");
|
||||||
|
const matcher = window.matchMedia("(prefers-color-scheme: light)");
|
||||||
|
const changer = (ev) => {
|
||||||
|
const style = getComputedStyle(document.documentElement);
|
||||||
|
let bg, text = "";
|
||||||
|
if (matcher.matches) {
|
||||||
|
bg = style.getPropertyValue('--pf-global--BackgroundColor--light-300');
|
||||||
|
text = style.getPropertyValue('--pf-global--Color--300');
|
||||||
|
} else {
|
||||||
|
bg = style.getPropertyValue('--ak-dark-background');
|
||||||
|
text = style.getPropertyValue('--ak-dark-foreground');
|
||||||
|
}
|
||||||
|
rapidoc.attributes.getNamedItem("bg-color").value = bg.trim();
|
||||||
|
rapidoc.attributes.getNamedItem("text-color").value = text.trim();
|
||||||
|
rapidoc.requestUpdate();
|
||||||
|
};
|
||||||
|
matcher.addEventListener("change", changer);
|
||||||
|
window.addEventListener("load", changer);
|
||||||
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@ -8,28 +8,37 @@ from rest_framework.exceptions import AuthenticationFailed
|
|||||||
|
|
||||||
from authentik.api.authentication import bearer_auth
|
from authentik.api.authentication import bearer_auth
|
||||||
from authentik.core.models import USER_ATTRIBUTE_SA, Token, TokenIntents
|
from authentik.core.models import USER_ATTRIBUTE_SA, Token, TokenIntents
|
||||||
|
from authentik.core.tests.utils import create_test_flow
|
||||||
|
from authentik.lib.generators import generate_id
|
||||||
from authentik.outposts.managed import OutpostManager
|
from authentik.outposts.managed import OutpostManager
|
||||||
|
from authentik.providers.oauth2.constants import SCOPE_AUTHENTIK_API
|
||||||
|
from authentik.providers.oauth2.models import OAuth2Provider, RefreshToken
|
||||||
|
|
||||||
|
|
||||||
class TestAPIAuth(TestCase):
|
class TestAPIAuth(TestCase):
|
||||||
"""Test API Authentication"""
|
"""Test API Authentication"""
|
||||||
|
|
||||||
def test_valid_bearer(self):
|
|
||||||
"""Test valid token"""
|
|
||||||
token = Token.objects.create(intent=TokenIntents.INTENT_API, user=get_anonymous_user())
|
|
||||||
self.assertEqual(bearer_auth(f"Bearer {token.key}".encode()), token.user)
|
|
||||||
|
|
||||||
def test_invalid_type(self):
|
def test_invalid_type(self):
|
||||||
"""Test invalid type"""
|
"""Test invalid type"""
|
||||||
with self.assertRaises(AuthenticationFailed):
|
with self.assertRaises(AuthenticationFailed):
|
||||||
bearer_auth("foo bar".encode())
|
bearer_auth("foo bar".encode())
|
||||||
|
|
||||||
|
def test_invalid_empty(self):
|
||||||
|
"""Test invalid type"""
|
||||||
|
self.assertIsNone(bearer_auth("Bearer ".encode()))
|
||||||
|
self.assertIsNone(bearer_auth("".encode()))
|
||||||
|
|
||||||
def test_invalid_no_token(self):
|
def test_invalid_no_token(self):
|
||||||
"""Test invalid with no token"""
|
"""Test invalid with no token"""
|
||||||
with self.assertRaises(AuthenticationFailed):
|
with self.assertRaises(AuthenticationFailed):
|
||||||
auth = b64encode(":abc".encode()).decode()
|
auth = b64encode(":abc".encode()).decode()
|
||||||
self.assertIsNone(bearer_auth(f"Basic :{auth}".encode()))
|
self.assertIsNone(bearer_auth(f"Basic :{auth}".encode()))
|
||||||
|
|
||||||
|
def test_bearer_valid(self):
|
||||||
|
"""Test valid token"""
|
||||||
|
token = Token.objects.create(intent=TokenIntents.INTENT_API, user=get_anonymous_user())
|
||||||
|
self.assertEqual(bearer_auth(f"Bearer {token.key}".encode()), token.user)
|
||||||
|
|
||||||
def test_managed_outpost(self):
|
def test_managed_outpost(self):
|
||||||
"""Test managed outpost"""
|
"""Test managed outpost"""
|
||||||
with self.assertRaises(AuthenticationFailed):
|
with self.assertRaises(AuthenticationFailed):
|
||||||
@ -38,3 +47,30 @@ class TestAPIAuth(TestCase):
|
|||||||
OutpostManager().run()
|
OutpostManager().run()
|
||||||
user = bearer_auth(f"Bearer {settings.SECRET_KEY}".encode())
|
user = bearer_auth(f"Bearer {settings.SECRET_KEY}".encode())
|
||||||
self.assertEqual(user.attributes[USER_ATTRIBUTE_SA], True)
|
self.assertEqual(user.attributes[USER_ATTRIBUTE_SA], True)
|
||||||
|
|
||||||
|
def test_jwt_valid(self):
|
||||||
|
"""Test valid JWT"""
|
||||||
|
provider = OAuth2Provider.objects.create(
|
||||||
|
name=generate_id(), client_id=generate_id(), authorization_flow=create_test_flow()
|
||||||
|
)
|
||||||
|
refresh = RefreshToken.objects.create(
|
||||||
|
user=get_anonymous_user(),
|
||||||
|
provider=provider,
|
||||||
|
refresh_token=generate_id(),
|
||||||
|
_scope=SCOPE_AUTHENTIK_API,
|
||||||
|
)
|
||||||
|
self.assertEqual(bearer_auth(f"Bearer {refresh.refresh_token}".encode()), refresh.user)
|
||||||
|
|
||||||
|
def test_jwt_missing_scope(self):
|
||||||
|
"""Test valid JWT"""
|
||||||
|
provider = OAuth2Provider.objects.create(
|
||||||
|
name=generate_id(), client_id=generate_id(), authorization_flow=create_test_flow()
|
||||||
|
)
|
||||||
|
refresh = RefreshToken.objects.create(
|
||||||
|
user=get_anonymous_user(),
|
||||||
|
provider=provider,
|
||||||
|
refresh_token=generate_id(),
|
||||||
|
_scope="",
|
||||||
|
)
|
||||||
|
with self.assertRaises(AuthenticationFailed):
|
||||||
|
self.assertEqual(bearer_auth(f"Bearer {refresh.refresh_token}".encode()), refresh.user)
|
||||||
|
|||||||
29
authentik/api/tests/test_viewsets.py
Normal file
29
authentik/api/tests/test_viewsets.py
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
"""authentik API Modelviewset tests"""
|
||||||
|
from typing import Callable
|
||||||
|
|
||||||
|
from django.test import TestCase
|
||||||
|
from rest_framework.viewsets import ModelViewSet, ReadOnlyModelViewSet
|
||||||
|
|
||||||
|
from authentik.api.v3.urls import router
|
||||||
|
|
||||||
|
|
||||||
|
class TestModelViewSets(TestCase):
|
||||||
|
"""Test Viewset"""
|
||||||
|
|
||||||
|
|
||||||
|
def viewset_tester_factory(test_viewset: type[ModelViewSet]) -> Callable:
|
||||||
|
"""Test Viewset"""
|
||||||
|
|
||||||
|
def tester(self: TestModelViewSets):
|
||||||
|
self.assertIsNotNone(getattr(test_viewset, "search_fields", None))
|
||||||
|
filterset_class = getattr(test_viewset, "filterset_class", None)
|
||||||
|
if not filterset_class:
|
||||||
|
self.assertIsNotNone(getattr(test_viewset, "filterset_fields", None))
|
||||||
|
|
||||||
|
return tester
|
||||||
|
|
||||||
|
|
||||||
|
for _, viewset, _ in router.registry:
|
||||||
|
if not issubclass(viewset, (ModelViewSet, ReadOnlyModelViewSet)):
|
||||||
|
continue
|
||||||
|
setattr(TestModelViewSets, f"test_viewset_{viewset.__name__}", viewset_tester_factory(viewset))
|
||||||
@ -74,7 +74,7 @@ class ConfigView(APIView):
|
|||||||
config = ConfigSerializer(
|
config = ConfigSerializer(
|
||||||
{
|
{
|
||||||
"error_reporting": {
|
"error_reporting": {
|
||||||
"enabled": CONFIG.y("error_reporting.enabled") and not settings.DEBUG,
|
"enabled": CONFIG.y("error_reporting.enabled"),
|
||||||
"environment": CONFIG.y("error_reporting.environment"),
|
"environment": CONFIG.y("error_reporting.environment"),
|
||||||
"send_pii": CONFIG.y("error_reporting.send_pii"),
|
"send_pii": CONFIG.y("error_reporting.send_pii"),
|
||||||
"traces_sample_rate": float(CONFIG.y("error_reporting.sample_rate", 0.4)),
|
"traces_sample_rate": float(CONFIG.y("error_reporting.sample_rate", 0.4)),
|
||||||
|
|||||||
@ -63,6 +63,7 @@ class ApplicationSerializer(ModelSerializer):
|
|||||||
"provider",
|
"provider",
|
||||||
"provider_obj",
|
"provider_obj",
|
||||||
"launch_url",
|
"launch_url",
|
||||||
|
"open_in_new_tab",
|
||||||
"meta_launch_url",
|
"meta_launch_url",
|
||||||
"meta_icon",
|
"meta_icon",
|
||||||
"meta_description",
|
"meta_description",
|
||||||
@ -88,7 +89,16 @@ class ApplicationViewSet(UsedByMixin, ModelViewSet):
|
|||||||
"meta_publisher",
|
"meta_publisher",
|
||||||
"group",
|
"group",
|
||||||
]
|
]
|
||||||
|
filterset_fields = [
|
||||||
|
"name",
|
||||||
|
"slug",
|
||||||
|
"meta_launch_url",
|
||||||
|
"meta_description",
|
||||||
|
"meta_publisher",
|
||||||
|
"group",
|
||||||
|
]
|
||||||
lookup_field = "slug"
|
lookup_field = "slug"
|
||||||
|
filterset_fields = ["name", "slug"]
|
||||||
ordering = ["name"]
|
ordering = ["name"]
|
||||||
|
|
||||||
def _filter_queryset_for_list(self, queryset: QuerySet) -> QuerySet:
|
def _filter_queryset_for_list(self, queryset: QuerySet) -> QuerySet:
|
||||||
|
|||||||
@ -8,7 +8,7 @@ from rest_framework.decorators import action
|
|||||||
from rest_framework.filters import OrderingFilter, SearchFilter
|
from rest_framework.filters import OrderingFilter, SearchFilter
|
||||||
from rest_framework.request import Request
|
from rest_framework.request import Request
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
from rest_framework.serializers import ModelSerializer, SerializerMethodField
|
from rest_framework.serializers import ModelSerializer, ReadOnlyField, SerializerMethodField
|
||||||
from rest_framework.viewsets import GenericViewSet
|
from rest_framework.viewsets import GenericViewSet
|
||||||
from structlog.stdlib import get_logger
|
from structlog.stdlib import get_logger
|
||||||
|
|
||||||
@ -26,6 +26,7 @@ LOGGER = get_logger()
|
|||||||
class SourceSerializer(ModelSerializer, MetaNameSerializer):
|
class SourceSerializer(ModelSerializer, MetaNameSerializer):
|
||||||
"""Source Serializer"""
|
"""Source Serializer"""
|
||||||
|
|
||||||
|
managed = ReadOnlyField()
|
||||||
component = SerializerMethodField()
|
component = SerializerMethodField()
|
||||||
|
|
||||||
def get_component(self, obj: Source) -> str:
|
def get_component(self, obj: Source) -> str:
|
||||||
@ -51,6 +52,8 @@ class SourceSerializer(ModelSerializer, MetaNameSerializer):
|
|||||||
"meta_model_name",
|
"meta_model_name",
|
||||||
"policy_engine_mode",
|
"policy_engine_mode",
|
||||||
"user_matching_mode",
|
"user_matching_mode",
|
||||||
|
"managed",
|
||||||
|
"user_path_template",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@ -66,6 +69,8 @@ class SourceViewSet(
|
|||||||
queryset = Source.objects.none()
|
queryset = Source.objects.none()
|
||||||
serializer_class = SourceSerializer
|
serializer_class = SourceSerializer
|
||||||
lookup_field = "slug"
|
lookup_field = "slug"
|
||||||
|
search_fields = ["slug", "name"]
|
||||||
|
filterset_fields = ["slug", "name", "managed"]
|
||||||
|
|
||||||
def get_queryset(self): # pragma: no cover
|
def get_queryset(self): # pragma: no cover
|
||||||
return Source.objects.select_subclasses()
|
return Source.objects.select_subclasses()
|
||||||
|
|||||||
@ -24,7 +24,13 @@ from drf_spectacular.utils import (
|
|||||||
)
|
)
|
||||||
from guardian.shortcuts import get_anonymous_user, get_objects_for_user
|
from guardian.shortcuts import get_anonymous_user, get_objects_for_user
|
||||||
from rest_framework.decorators import action
|
from rest_framework.decorators import action
|
||||||
from rest_framework.fields import CharField, JSONField, SerializerMethodField
|
from rest_framework.fields import (
|
||||||
|
CharField,
|
||||||
|
IntegerField,
|
||||||
|
JSONField,
|
||||||
|
ListField,
|
||||||
|
SerializerMethodField,
|
||||||
|
)
|
||||||
from rest_framework.request import Request
|
from rest_framework.request import Request
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
from rest_framework.serializers import (
|
from rest_framework.serializers import (
|
||||||
@ -43,16 +49,23 @@ from authentik.api.decorators import permission_required
|
|||||||
from authentik.core.api.groups import GroupSerializer
|
from authentik.core.api.groups import GroupSerializer
|
||||||
from authentik.core.api.used_by import UsedByMixin
|
from authentik.core.api.used_by import UsedByMixin
|
||||||
from authentik.core.api.utils import LinkSerializer, PassiveSerializer, is_dict
|
from authentik.core.api.utils import LinkSerializer, PassiveSerializer, is_dict
|
||||||
from authentik.core.middleware import SESSION_IMPERSONATE_ORIGINAL_USER, SESSION_IMPERSONATE_USER
|
from authentik.core.middleware import (
|
||||||
|
SESSION_KEY_IMPERSONATE_ORIGINAL_USER,
|
||||||
|
SESSION_KEY_IMPERSONATE_USER,
|
||||||
|
)
|
||||||
from authentik.core.models import (
|
from authentik.core.models import (
|
||||||
USER_ATTRIBUTE_SA,
|
USER_ATTRIBUTE_SA,
|
||||||
USER_ATTRIBUTE_TOKEN_EXPIRING,
|
USER_ATTRIBUTE_TOKEN_EXPIRING,
|
||||||
|
USER_PATH_SERVICE_ACCOUNT,
|
||||||
Group,
|
Group,
|
||||||
Token,
|
Token,
|
||||||
TokenIntents,
|
TokenIntents,
|
||||||
User,
|
User,
|
||||||
)
|
)
|
||||||
from authentik.events.models import EventAction
|
from authentik.events.models import EventAction
|
||||||
|
from authentik.flows.models import FlowToken
|
||||||
|
from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlanner
|
||||||
|
from authentik.flows.views.executor import QS_KEY_TOKEN
|
||||||
from authentik.stages.email.models import EmailStage
|
from authentik.stages.email.models import EmailStage
|
||||||
from authentik.stages.email.tasks import send_mails
|
from authentik.stages.email.tasks import send_mails
|
||||||
from authentik.stages.email.utils import TemplateEmailMessage
|
from authentik.stages.email.utils import TemplateEmailMessage
|
||||||
@ -72,6 +85,16 @@ class UserSerializer(ModelSerializer):
|
|||||||
)
|
)
|
||||||
groups_obj = ListSerializer(child=GroupSerializer(), read_only=True, source="ak_groups")
|
groups_obj = ListSerializer(child=GroupSerializer(), read_only=True, source="ak_groups")
|
||||||
uid = CharField(read_only=True)
|
uid = CharField(read_only=True)
|
||||||
|
username = CharField(max_length=150)
|
||||||
|
|
||||||
|
def validate_path(self, path: str) -> str:
|
||||||
|
"""Validate path"""
|
||||||
|
if path[:1] == "/" or path[-1] == "/":
|
||||||
|
raise ValidationError(_("No leading or trailing slashes allowed."))
|
||||||
|
for segment in path.split("/"):
|
||||||
|
if segment == "":
|
||||||
|
raise ValidationError(_("No empty segments in user path allowed."))
|
||||||
|
return path
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
|
||||||
@ -89,6 +112,7 @@ class UserSerializer(ModelSerializer):
|
|||||||
"avatar",
|
"avatar",
|
||||||
"attributes",
|
"attributes",
|
||||||
"uid",
|
"uid",
|
||||||
|
"path",
|
||||||
]
|
]
|
||||||
extra_kwargs = {
|
extra_kwargs = {
|
||||||
"name": {"allow_blank": True},
|
"name": {"allow_blank": True},
|
||||||
@ -204,6 +228,11 @@ class UsersFilter(FilterSet):
|
|||||||
is_superuser = BooleanFilter(field_name="ak_groups", lookup_expr="is_superuser")
|
is_superuser = BooleanFilter(field_name="ak_groups", lookup_expr="is_superuser")
|
||||||
uuid = CharFilter(field_name="uuid")
|
uuid = CharFilter(field_name="uuid")
|
||||||
|
|
||||||
|
path = CharFilter(
|
||||||
|
field_name="path",
|
||||||
|
)
|
||||||
|
path_startswith = CharFilter(field_name="path", lookup_expr="startswith")
|
||||||
|
|
||||||
groups_by_name = ModelMultipleChoiceFilter(
|
groups_by_name = ModelMultipleChoiceFilter(
|
||||||
field_name="ak_groups__name",
|
field_name="ak_groups__name",
|
||||||
to_field_name="name",
|
to_field_name="name",
|
||||||
@ -268,12 +297,23 @@ class UserViewSet(UsedByMixin, ModelViewSet):
|
|||||||
LOGGER.debug("No recovery flow set")
|
LOGGER.debug("No recovery flow set")
|
||||||
return None, None
|
return None, None
|
||||||
user: User = self.get_object()
|
user: User = self.get_object()
|
||||||
token, __ = Token.objects.get_or_create(
|
planner = FlowPlanner(flow)
|
||||||
identifier=f"{user.uid}-password-reset",
|
planner.allow_empty_flows = True
|
||||||
user=user,
|
plan = planner.plan(
|
||||||
intent=TokenIntents.INTENT_RECOVERY,
|
self.request._request,
|
||||||
|
{
|
||||||
|
PLAN_CONTEXT_PENDING_USER: user,
|
||||||
|
},
|
||||||
)
|
)
|
||||||
querystring = urlencode({"token": token.key})
|
token, __ = FlowToken.objects.update_or_create(
|
||||||
|
identifier=f"{user.uid}-password-reset",
|
||||||
|
defaults={
|
||||||
|
"user": user,
|
||||||
|
"flow": flow,
|
||||||
|
"_plan": FlowToken.pickle(plan),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
querystring = urlencode({QS_KEY_TOKEN: token.key})
|
||||||
link = self.request.build_absolute_uri(
|
link = self.request.build_absolute_uri(
|
||||||
reverse_lazy("authentik_core:if-flow", kwargs={"flow_slug": flow.slug})
|
reverse_lazy("authentik_core:if-flow", kwargs={"flow_slug": flow.slug})
|
||||||
+ f"?{querystring}"
|
+ f"?{querystring}"
|
||||||
@ -295,6 +335,9 @@ class UserViewSet(UsedByMixin, ModelViewSet):
|
|||||||
{
|
{
|
||||||
"username": CharField(required=True),
|
"username": CharField(required=True),
|
||||||
"token": CharField(required=True),
|
"token": CharField(required=True),
|
||||||
|
"user_uid": CharField(required=True),
|
||||||
|
"user_pk": IntegerField(required=True),
|
||||||
|
"group_pk": CharField(required=False),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
@ -310,19 +353,27 @@ class UserViewSet(UsedByMixin, ModelViewSet):
|
|||||||
username=username,
|
username=username,
|
||||||
name=username,
|
name=username,
|
||||||
attributes={USER_ATTRIBUTE_SA: True, USER_ATTRIBUTE_TOKEN_EXPIRING: False},
|
attributes={USER_ATTRIBUTE_SA: True, USER_ATTRIBUTE_TOKEN_EXPIRING: False},
|
||||||
|
path=USER_PATH_SERVICE_ACCOUNT,
|
||||||
)
|
)
|
||||||
|
response = {
|
||||||
|
"username": user.username,
|
||||||
|
"user_uid": user.uid,
|
||||||
|
"user_pk": user.pk,
|
||||||
|
}
|
||||||
if create_group and self.request.user.has_perm("authentik_core.add_group"):
|
if create_group and self.request.user.has_perm("authentik_core.add_group"):
|
||||||
group = Group.objects.create(
|
group = Group.objects.create(
|
||||||
name=username,
|
name=username,
|
||||||
)
|
)
|
||||||
group.users.add(user)
|
group.users.add(user)
|
||||||
|
response["group_pk"] = str(group.pk)
|
||||||
token = Token.objects.create(
|
token = Token.objects.create(
|
||||||
identifier=slugify(f"service-account-{username}-password"),
|
identifier=slugify(f"service-account-{username}-password"),
|
||||||
intent=TokenIntents.INTENT_APP_PASSWORD,
|
intent=TokenIntents.INTENT_APP_PASSWORD,
|
||||||
user=user,
|
user=user,
|
||||||
expires=now() + timedelta(days=360),
|
expires=now() + timedelta(days=360),
|
||||||
)
|
)
|
||||||
return Response({"username": user.username, "token": token.key})
|
response["token"] = token.key
|
||||||
|
return Response(response)
|
||||||
except (IntegrityError) as exc:
|
except (IntegrityError) as exc:
|
||||||
return Response(data={"non_field_errors": [str(exc)]}, status=400)
|
return Response(data={"non_field_errors": [str(exc)]}, status=400)
|
||||||
|
|
||||||
@ -335,11 +386,12 @@ class UserViewSet(UsedByMixin, ModelViewSet):
|
|||||||
serializer = SessionUserSerializer(
|
serializer = SessionUserSerializer(
|
||||||
data={"user": UserSelfSerializer(instance=request.user, context=context).data}
|
data={"user": UserSelfSerializer(instance=request.user, context=context).data}
|
||||||
)
|
)
|
||||||
if SESSION_IMPERSONATE_USER in request._request.session:
|
if SESSION_KEY_IMPERSONATE_USER in request._request.session:
|
||||||
serializer.initial_data["original"] = UserSelfSerializer(
|
serializer.initial_data["original"] = UserSelfSerializer(
|
||||||
instance=request._request.session[SESSION_IMPERSONATE_ORIGINAL_USER],
|
instance=request._request.session[SESSION_KEY_IMPERSONATE_ORIGINAL_USER],
|
||||||
context=context,
|
context=context,
|
||||||
).data
|
).data
|
||||||
|
self.request.session.modified = True
|
||||||
return Response(serializer.initial_data)
|
return Response(serializer.initial_data)
|
||||||
|
|
||||||
@permission_required("authentik_core.reset_user_password")
|
@permission_required("authentik_core.reset_user_password")
|
||||||
@ -366,7 +418,7 @@ class UserViewSet(UsedByMixin, ModelViewSet):
|
|||||||
except (ValidationError, IntegrityError) as exc:
|
except (ValidationError, IntegrityError) as exc:
|
||||||
LOGGER.debug("Failed to set password", exc=exc)
|
LOGGER.debug("Failed to set password", exc=exc)
|
||||||
return Response(status=400)
|
return Response(status=400)
|
||||||
if user.pk == request.user.pk and SESSION_IMPERSONATE_USER not in self.request.session:
|
if user.pk == request.user.pk and SESSION_KEY_IMPERSONATE_USER not in self.request.session:
|
||||||
LOGGER.debug("Updating session hash after password change")
|
LOGGER.debug("Updating session hash after password change")
|
||||||
update_session_auth_hash(self.request, user)
|
update_session_auth_hash(self.request, user)
|
||||||
return Response(status=204)
|
return Response(status=204)
|
||||||
@ -459,3 +511,32 @@ class UserViewSet(UsedByMixin, ModelViewSet):
|
|||||||
if self.request.user.has_perm("authentik_core.view_user"):
|
if self.request.user.has_perm("authentik_core.view_user"):
|
||||||
return self._filter_queryset_for_list(queryset)
|
return self._filter_queryset_for_list(queryset)
|
||||||
return super().filter_queryset(queryset)
|
return super().filter_queryset(queryset)
|
||||||
|
|
||||||
|
@extend_schema(
|
||||||
|
responses={
|
||||||
|
200: inline_serializer(
|
||||||
|
"UserPathSerializer", {"paths": ListField(child=CharField(), read_only=True)}
|
||||||
|
)
|
||||||
|
},
|
||||||
|
parameters=[
|
||||||
|
OpenApiParameter(
|
||||||
|
name="search",
|
||||||
|
location=OpenApiParameter.QUERY,
|
||||||
|
type=OpenApiTypes.STR,
|
||||||
|
)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
@action(detail=False, pagination_class=None)
|
||||||
|
def paths(self, request: Request) -> Response:
|
||||||
|
"""Get all user paths"""
|
||||||
|
return Response(
|
||||||
|
data={
|
||||||
|
"paths": list(
|
||||||
|
self.filter_queryset(self.get_queryset())
|
||||||
|
.values("path")
|
||||||
|
.distinct()
|
||||||
|
.order_by("path")
|
||||||
|
.values_list("path", flat=True)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|||||||
@ -2,10 +2,7 @@
|
|||||||
from importlib import import_module
|
from importlib import import_module
|
||||||
|
|
||||||
from django.apps import AppConfig
|
from django.apps import AppConfig
|
||||||
from django.db import ProgrammingError
|
from django.conf import settings
|
||||||
|
|
||||||
from authentik.core.signals import GAUGE_MODELS
|
|
||||||
from authentik.lib.utils.reflection import get_apps
|
|
||||||
|
|
||||||
|
|
||||||
class AuthentikCoreConfig(AppConfig):
|
class AuthentikCoreConfig(AppConfig):
|
||||||
@ -19,12 +16,7 @@ class AuthentikCoreConfig(AppConfig):
|
|||||||
def ready(self):
|
def ready(self):
|
||||||
import_module("authentik.core.signals")
|
import_module("authentik.core.signals")
|
||||||
import_module("authentik.core.managed")
|
import_module("authentik.core.managed")
|
||||||
try:
|
if settings.DEBUG:
|
||||||
for app in get_apps():
|
from authentik.root.celery import worker_ready_hook
|
||||||
for model in app.get_models():
|
|
||||||
GAUGE_MODELS.labels(
|
worker_ready_hook()
|
||||||
model_name=model._meta.model_name,
|
|
||||||
app=model._meta.app_label,
|
|
||||||
).set(model.objects.count())
|
|
||||||
except ProgrammingError:
|
|
||||||
pass
|
|
||||||
|
|||||||
@ -12,5 +12,6 @@ class CoreManager(ObjectManager):
|
|||||||
Source,
|
Source,
|
||||||
"goauthentik.io/sources/inbuilt",
|
"goauthentik.io/sources/inbuilt",
|
||||||
name="authentik Built-in",
|
name="authentik Built-in",
|
||||||
|
slug="authentik-built-in",
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|||||||
13
authentik/core/management/commands/bootstrap_tasks.py
Normal file
13
authentik/core/management/commands/bootstrap_tasks.py
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
"""Run bootstrap tasks"""
|
||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
|
||||||
|
from authentik.root.celery import _get_startup_tasks
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand): # pragma: no cover
|
||||||
|
"""Run bootstrap tasks to ensure certain objects are created"""
|
||||||
|
|
||||||
|
def handle(self, **options):
|
||||||
|
tasks = _get_startup_tasks()
|
||||||
|
for task in tasks:
|
||||||
|
task()
|
||||||
@ -2,6 +2,7 @@
|
|||||||
from django.apps import apps
|
from django.apps import apps
|
||||||
from django.contrib.auth.management import create_permissions
|
from django.contrib.auth.management import create_permissions
|
||||||
from django.core.management.base import BaseCommand, no_translations
|
from django.core.management.base import BaseCommand, no_translations
|
||||||
|
from guardian.management import create_anonymous_user
|
||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand): # pragma: no cover
|
class Command(BaseCommand): # pragma: no cover
|
||||||
@ -13,3 +14,4 @@ class Command(BaseCommand): # pragma: no cover
|
|||||||
for app in apps.get_app_configs():
|
for app in apps.get_app_configs():
|
||||||
self.stdout.write(f"Checking app {app.name} ({app.label})\n")
|
self.stdout.write(f"Checking app {app.name} ({app.label})\n")
|
||||||
create_permissions(app, verbosity=0)
|
create_permissions(app, verbosity=0)
|
||||||
|
create_anonymous_user(None, using="default")
|
||||||
@ -7,8 +7,8 @@ from uuid import uuid4
|
|||||||
from django.http import HttpRequest, HttpResponse
|
from django.http import HttpRequest, HttpResponse
|
||||||
from sentry_sdk.api import set_tag
|
from sentry_sdk.api import set_tag
|
||||||
|
|
||||||
SESSION_IMPERSONATE_USER = "authentik_impersonate_user"
|
SESSION_KEY_IMPERSONATE_USER = "authentik/impersonate/user"
|
||||||
SESSION_IMPERSONATE_ORIGINAL_USER = "authentik_impersonate_original_user"
|
SESSION_KEY_IMPERSONATE_ORIGINAL_USER = "authentik/impersonate/original_user"
|
||||||
LOCAL = local()
|
LOCAL = local()
|
||||||
RESPONSE_HEADER_ID = "X-authentik-id"
|
RESPONSE_HEADER_ID = "X-authentik-id"
|
||||||
KEY_AUTH_VIA = "auth_via"
|
KEY_AUTH_VIA = "auth_via"
|
||||||
@ -25,10 +25,10 @@ class ImpersonateMiddleware:
|
|||||||
|
|
||||||
def __call__(self, request: HttpRequest) -> HttpResponse:
|
def __call__(self, request: HttpRequest) -> HttpResponse:
|
||||||
# No permission checks are done here, they need to be checked before
|
# No permission checks are done here, they need to be checked before
|
||||||
# SESSION_IMPERSONATE_USER is set.
|
# SESSION_KEY_IMPERSONATE_USER is set.
|
||||||
|
|
||||||
if SESSION_IMPERSONATE_USER in request.session:
|
if SESSION_KEY_IMPERSONATE_USER in request.session:
|
||||||
request.user = request.session[SESSION_IMPERSONATE_USER]
|
request.user = request.session[SESSION_KEY_IMPERSONATE_USER]
|
||||||
# Ensure that the user is active, otherwise nothing will work
|
# Ensure that the user is active, otherwise nothing will work
|
||||||
request.user.is_active = True
|
request.user.is_active = True
|
||||||
|
|
||||||
@ -56,7 +56,7 @@ class RequestIDMiddleware:
|
|||||||
response[RESPONSE_HEADER_ID] = request.request_id
|
response[RESPONSE_HEADER_ID] = request.request_id
|
||||||
setattr(response, "ak_context", {})
|
setattr(response, "ak_context", {})
|
||||||
response.ak_context.update(LOCAL.authentik)
|
response.ak_context.update(LOCAL.authentik)
|
||||||
response.ak_context[KEY_USER] = request.user.username
|
response.ak_context.setdefault(KEY_USER, request.user.username)
|
||||||
for key in list(LOCAL.authentik.keys()):
|
for key in list(LOCAL.authentik.keys()):
|
||||||
del LOCAL.authentik[key]
|
del LOCAL.authentik[key]
|
||||||
return response
|
return response
|
||||||
|
|||||||
@ -12,18 +12,25 @@ import authentik.core.models
|
|||||||
|
|
||||||
|
|
||||||
def create_default_user(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
|
def create_default_user(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
|
||||||
# We have to use a direct import here, otherwise we get an object manager error
|
from django.contrib.auth.hashers import make_password
|
||||||
from authentik.core.models import User
|
|
||||||
|
|
||||||
|
User = apps.get_model("authentik_core", "User")
|
||||||
db_alias = schema_editor.connection.alias
|
db_alias = schema_editor.connection.alias
|
||||||
|
|
||||||
akadmin, _ = User.objects.using(db_alias).get_or_create(
|
akadmin, _ = User.objects.using(db_alias).get_or_create(
|
||||||
username="akadmin", email="root@localhost", name="authentik Default Admin"
|
username="akadmin", email="root@localhost", name="authentik Default Admin"
|
||||||
)
|
)
|
||||||
if "TF_BUILD" in environ or "AK_ADMIN_PASS" in environ or settings.TEST:
|
password = None
|
||||||
akadmin.set_password(environ.get("AK_ADMIN_PASS", "akadmin"), signal=False) # noqa # nosec
|
if "TF_BUILD" in environ or settings.TEST:
|
||||||
|
password = "akadmin" # noqa # nosec
|
||||||
|
if "AK_ADMIN_PASS" in environ:
|
||||||
|
password = environ["AK_ADMIN_PASS"]
|
||||||
|
if "AUTHENTIK_BOOTSTRAP_PASSWORD" in environ:
|
||||||
|
password = environ["AUTHENTIK_BOOTSTRAP_PASSWORD"]
|
||||||
|
if password:
|
||||||
|
akadmin.password = make_password(password)
|
||||||
else:
|
else:
|
||||||
akadmin.set_unusable_password()
|
akadmin.password = make_password(None)
|
||||||
akadmin.save()
|
akadmin.save()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -8,18 +8,25 @@ from django.db.backends.base.schema import BaseDatabaseSchemaEditor
|
|||||||
|
|
||||||
|
|
||||||
def create_default_user(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
|
def create_default_user(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
|
||||||
# We have to use a direct import here, otherwise we get an object manager error
|
from django.contrib.auth.hashers import make_password
|
||||||
from authentik.core.models import User
|
|
||||||
|
|
||||||
|
User = apps.get_model("authentik_core", "User")
|
||||||
db_alias = schema_editor.connection.alias
|
db_alias = schema_editor.connection.alias
|
||||||
|
|
||||||
akadmin, _ = User.objects.using(db_alias).get_or_create(
|
akadmin, _ = User.objects.using(db_alias).get_or_create(
|
||||||
username="akadmin", email="root@localhost", name="authentik Default Admin"
|
username="akadmin", email="root@localhost", name="authentik Default Admin"
|
||||||
)
|
)
|
||||||
if "TF_BUILD" in environ or "AK_ADMIN_PASS" in environ or settings.TEST:
|
password = None
|
||||||
akadmin.set_password(environ.get("AK_ADMIN_PASS", "akadmin"), signal=False) # noqa # nosec
|
if "TF_BUILD" in environ or settings.TEST:
|
||||||
|
password = "akadmin" # noqa # nosec
|
||||||
|
if "AK_ADMIN_PASS" in environ:
|
||||||
|
password = environ["AK_ADMIN_PASS"]
|
||||||
|
if "AUTHENTIK_BOOTSTRAP_PASSWORD" in environ:
|
||||||
|
password = environ["AUTHENTIK_BOOTSTRAP_PASSWORD"]
|
||||||
|
if password:
|
||||||
|
akadmin.password = make_password(password)
|
||||||
else:
|
else:
|
||||||
akadmin.set_unusable_password()
|
akadmin.password = make_password(None)
|
||||||
akadmin.save()
|
akadmin.save()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -36,22 +36,29 @@ def fix_duplicates(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
|
|||||||
|
|
||||||
|
|
||||||
def create_default_user_token(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
|
def create_default_user_token(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
|
||||||
# We have to use a direct import here, otherwise we get an object manager error
|
from authentik.core.models import TokenIntents
|
||||||
from authentik.core.models import Token, TokenIntents, User
|
|
||||||
|
User = apps.get_model("authentik_core", "User")
|
||||||
|
Token = apps.get_model("authentik_core", "Token")
|
||||||
|
|
||||||
db_alias = schema_editor.connection.alias
|
db_alias = schema_editor.connection.alias
|
||||||
|
|
||||||
akadmin = User.objects.using(db_alias).filter(username="akadmin")
|
akadmin = User.objects.using(db_alias).filter(username="akadmin")
|
||||||
if not akadmin.exists():
|
if not akadmin.exists():
|
||||||
return
|
return
|
||||||
if "AK_ADMIN_TOKEN" not in environ:
|
key = None
|
||||||
|
if "AK_ADMIN_TOKEN" in environ:
|
||||||
|
key = environ["AK_ADMIN_TOKEN"]
|
||||||
|
if "AUTHENTIK_BOOTSTRAP_TOKEN" in environ:
|
||||||
|
key = environ["AUTHENTIK_BOOTSTRAP_TOKEN"]
|
||||||
|
if not key:
|
||||||
return
|
return
|
||||||
Token.objects.using(db_alias).create(
|
Token.objects.using(db_alias).create(
|
||||||
identifier="authentik-boostrap-token",
|
identifier="authentik-bootstrap-token",
|
||||||
user=akadmin.first(),
|
user=akadmin.first(),
|
||||||
intent=TokenIntents.INTENT_API,
|
intent=TokenIntents.INTENT_API,
|
||||||
expiring=False,
|
expiring=False,
|
||||||
key=environ["AK_ADMIN_TOKEN"],
|
key=key,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -0,0 +1,20 @@
|
|||||||
|
# Generated by Django 4.0.5 on 2022-06-04 06:54
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("authentik_core", "0019_application_group"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="application",
|
||||||
|
name="open_in_new_tab",
|
||||||
|
field=models.BooleanField(
|
||||||
|
default=False, help_text="Open launch URL in a new browser tab or window."
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
23
authentik/core/migrations/0021_source_user_path_user_path.py
Normal file
23
authentik/core/migrations/0021_source_user_path_user_path.py
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
# Generated by Django 4.0.5 on 2022-06-13 18:51
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("authentik_core", "0020_application_open_in_new_tab"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="source",
|
||||||
|
name="user_path_template",
|
||||||
|
field=models.TextField(default="goauthentik.io/sources/%(slug)s"),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="user",
|
||||||
|
name="path",
|
||||||
|
field=models.TextField(default="users"),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -7,22 +7,29 @@ from django.db.backends.base.schema import BaseDatabaseSchemaEditor
|
|||||||
|
|
||||||
|
|
||||||
def create_default_user_token(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
|
def create_default_user_token(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
|
||||||
# We have to use a direct import here, otherwise we get an object manager error
|
from authentik.core.models import TokenIntents
|
||||||
from authentik.core.models import Token, TokenIntents, User
|
|
||||||
|
User = apps.get_model("authentik_core", "User")
|
||||||
|
Token = apps.get_model("authentik_core", "Token")
|
||||||
|
|
||||||
db_alias = schema_editor.connection.alias
|
db_alias = schema_editor.connection.alias
|
||||||
|
|
||||||
akadmin = User.objects.using(db_alias).filter(username="akadmin")
|
akadmin = User.objects.using(db_alias).filter(username="akadmin")
|
||||||
if not akadmin.exists():
|
if not akadmin.exists():
|
||||||
return
|
return
|
||||||
if "AK_ADMIN_TOKEN" not in environ:
|
key = None
|
||||||
|
if "AK_ADMIN_TOKEN" in environ:
|
||||||
|
key = environ["AK_ADMIN_TOKEN"]
|
||||||
|
if "AUTHENTIK_BOOTSTRAP_TOKEN" in environ:
|
||||||
|
key = environ["AUTHENTIK_BOOTSTRAP_TOKEN"]
|
||||||
|
if not key:
|
||||||
return
|
return
|
||||||
Token.objects.using(db_alias).create(
|
Token.objects.using(db_alias).create(
|
||||||
identifier="authentik-boostrap-token",
|
identifier="authentik-bootstrap-token",
|
||||||
user=akadmin.first(),
|
user=akadmin.first(),
|
||||||
intent=TokenIntents.INTENT_API,
|
intent=TokenIntents.INTENT_API,
|
||||||
expiring=False,
|
expiring=False,
|
||||||
key=environ["AK_ADMIN_TOKEN"],
|
key=key,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -46,6 +46,9 @@ USER_ATTRIBUTE_CHANGE_NAME = "goauthentik.io/user/can-change-name"
|
|||||||
USER_ATTRIBUTE_CHANGE_EMAIL = "goauthentik.io/user/can-change-email"
|
USER_ATTRIBUTE_CHANGE_EMAIL = "goauthentik.io/user/can-change-email"
|
||||||
USER_ATTRIBUTE_CAN_OVERRIDE_IP = "goauthentik.io/user/override-ips"
|
USER_ATTRIBUTE_CAN_OVERRIDE_IP = "goauthentik.io/user/override-ips"
|
||||||
|
|
||||||
|
USER_PATH_SYSTEM_PREFIX = "goauthentik.io"
|
||||||
|
USER_PATH_SERVICE_ACCOUNT = USER_PATH_SYSTEM_PREFIX + "/service-accounts"
|
||||||
|
|
||||||
GRAVATAR_URL = "https://secure.gravatar.com"
|
GRAVATAR_URL = "https://secure.gravatar.com"
|
||||||
DEFAULT_AVATAR = static("dist/assets/images/user_default.png")
|
DEFAULT_AVATAR = static("dist/assets/images/user_default.png")
|
||||||
|
|
||||||
@ -103,7 +106,10 @@ class Group(models.Model):
|
|||||||
|
|
||||||
SELECT authentik_core_group.*, parents.relative_depth - 1
|
SELECT authentik_core_group.*, parents.relative_depth - 1
|
||||||
FROM authentik_core_group,parents
|
FROM authentik_core_group,parents
|
||||||
WHERE authentik_core_group.parent_id = parents.group_uuid
|
WHERE (
|
||||||
|
authentik_core_group.parent_id = parents.group_uuid and
|
||||||
|
parents.relative_depth > -20
|
||||||
|
)
|
||||||
)
|
)
|
||||||
SELECT group_uuid
|
SELECT group_uuid
|
||||||
FROM parents
|
FROM parents
|
||||||
@ -138,6 +144,7 @@ class User(GuardianUserMixin, AbstractUser):
|
|||||||
|
|
||||||
uuid = models.UUIDField(default=uuid4, editable=False)
|
uuid = models.UUIDField(default=uuid4, editable=False)
|
||||||
name = models.TextField(help_text=_("User's display name."))
|
name = models.TextField(help_text=_("User's display name."))
|
||||||
|
path = models.TextField(default="users")
|
||||||
|
|
||||||
sources = models.ManyToManyField("Source", through="UserSourceConnection")
|
sources = models.ManyToManyField("Source", through="UserSourceConnection")
|
||||||
ak_groups = models.ManyToManyField("Group", related_name="users")
|
ak_groups = models.ManyToManyField("Group", related_name="users")
|
||||||
@ -147,6 +154,11 @@ class User(GuardianUserMixin, AbstractUser):
|
|||||||
|
|
||||||
objects = UserManager()
|
objects = UserManager()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def default_path() -> str:
|
||||||
|
"""Get the default user path"""
|
||||||
|
return User._meta.get_field("path").default
|
||||||
|
|
||||||
def group_attributes(self, request: Optional[HttpRequest] = None) -> dict[str, Any]:
|
def group_attributes(self, request: Optional[HttpRequest] = None) -> dict[str, Any]:
|
||||||
"""Get a dictionary containing the attributes from all groups the user belongs to,
|
"""Get a dictionary containing the attributes from all groups the user belongs to,
|
||||||
including the users attributes"""
|
including the users attributes"""
|
||||||
@ -192,7 +204,7 @@ class User(GuardianUserMixin, AbstractUser):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def uid(self) -> str:
|
def uid(self) -> str:
|
||||||
"""Generate a globall unique UID, based on the user ID and the hashed secret key"""
|
"""Generate a globally unique UID, based on the user ID and the hashed secret key"""
|
||||||
return sha256(f"{self.id}-{settings.SECRET_KEY}".encode("ascii")).hexdigest()
|
return sha256(f"{self.id}-{settings.SECRET_KEY}".encode("ascii")).hexdigest()
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@ -278,6 +290,11 @@ class Application(PolicyBindingModel):
|
|||||||
meta_launch_url = models.TextField(
|
meta_launch_url = models.TextField(
|
||||||
default="", blank=True, validators=[DomainlessURLValidator()]
|
default="", blank=True, validators=[DomainlessURLValidator()]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
open_in_new_tab = models.BooleanField(
|
||||||
|
default=False, help_text=_("Open launch URL in a new browser tab or window.")
|
||||||
|
)
|
||||||
|
|
||||||
# For template applications, this can be set to /static/authentik/applications/*
|
# For template applications, this can be set to /static/authentik/applications/*
|
||||||
meta_icon = models.FileField(
|
meta_icon = models.FileField(
|
||||||
upload_to="application-icons/",
|
upload_to="application-icons/",
|
||||||
@ -368,6 +385,8 @@ class Source(ManagedModel, SerializerModel, PolicyBindingModel):
|
|||||||
name = models.TextField(help_text=_("Source's display Name."))
|
name = models.TextField(help_text=_("Source's display Name."))
|
||||||
slug = models.SlugField(help_text=_("Internal source name, used in URLs."), unique=True)
|
slug = models.SlugField(help_text=_("Internal source name, used in URLs."), unique=True)
|
||||||
|
|
||||||
|
user_path_template = models.TextField(default="goauthentik.io/sources/%(slug)s")
|
||||||
|
|
||||||
enabled = models.BooleanField(default=True)
|
enabled = models.BooleanField(default=True)
|
||||||
property_mappings = models.ManyToManyField("PropertyMapping", default=None, blank=True)
|
property_mappings = models.ManyToManyField("PropertyMapping", default=None, blank=True)
|
||||||
|
|
||||||
@ -403,6 +422,17 @@ class Source(ManagedModel, SerializerModel, PolicyBindingModel):
|
|||||||
|
|
||||||
objects = InheritanceManager()
|
objects = InheritanceManager()
|
||||||
|
|
||||||
|
def get_user_path(self) -> str:
|
||||||
|
"""Get user path, fallback to default for formatting errors"""
|
||||||
|
try:
|
||||||
|
return self.user_path_template % {
|
||||||
|
"slug": self.slug,
|
||||||
|
}
|
||||||
|
# pylint: disable=broad-except
|
||||||
|
except Exception as exc:
|
||||||
|
LOGGER.warning("Failed to template user path", exc=exc, source=self)
|
||||||
|
return User.default_path()
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def component(self) -> str:
|
def component(self) -> str:
|
||||||
"""Return component used to edit this object"""
|
"""Return component used to edit this object"""
|
||||||
@ -452,8 +482,9 @@ class ExpiringModel(models.Model):
|
|||||||
def filter_not_expired(cls, **kwargs) -> QuerySet:
|
def filter_not_expired(cls, **kwargs) -> QuerySet:
|
||||||
"""Filer for tokens which are not expired yet or are not expiring,
|
"""Filer for tokens which are not expired yet or are not expiring,
|
||||||
and match filters in `kwargs`"""
|
and match filters in `kwargs`"""
|
||||||
expired = Q(expires__lt=now(), expiring=True)
|
for obj in cls.objects.filter(**kwargs).filter(Q(expires__lt=now(), expiring=True)):
|
||||||
return cls.objects.exclude(expired).filter(**kwargs)
|
obj.delete()
|
||||||
|
return cls.objects.filter(**kwargs)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_expired(self) -> bool:
|
def is_expired(self) -> bool:
|
||||||
|
|||||||
@ -1,7 +1,6 @@
|
|||||||
"""authentik core signals"""
|
"""authentik core signals"""
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from django.apps import apps
|
|
||||||
from django.contrib.auth.signals import user_logged_in, user_logged_out
|
from django.contrib.auth.signals import user_logged_in, user_logged_out
|
||||||
from django.contrib.sessions.backends.cache import KEY_PREFIX
|
from django.contrib.sessions.backends.cache import KEY_PREFIX
|
||||||
from django.core.cache import cache
|
from django.core.cache import cache
|
||||||
@ -10,30 +9,16 @@ from django.db.models import Model
|
|||||||
from django.db.models.signals import post_save, pre_delete
|
from django.db.models.signals import post_save, pre_delete
|
||||||
from django.dispatch import receiver
|
from django.dispatch import receiver
|
||||||
from django.http.request import HttpRequest
|
from django.http.request import HttpRequest
|
||||||
from prometheus_client import Gauge
|
|
||||||
|
|
||||||
from authentik.root.monitoring import monitoring_set
|
|
||||||
|
|
||||||
# Arguments: user: User, password: str
|
# Arguments: user: User, password: str
|
||||||
password_changed = Signal()
|
password_changed = Signal()
|
||||||
|
# Arguments: credentials: dict[str, any], request: HttpRequest, stage: Stage
|
||||||
GAUGE_MODELS = Gauge("authentik_models", "Count of various objects", ["model_name", "app"])
|
login_failed = Signal()
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from authentik.core.models import AuthenticatedSession, User
|
from authentik.core.models import AuthenticatedSession, User
|
||||||
|
|
||||||
|
|
||||||
@receiver(monitoring_set)
|
|
||||||
# pylint: disable=unused-argument
|
|
||||||
def monitoring_set_models(sender, **kwargs):
|
|
||||||
"""set models gauges"""
|
|
||||||
for model in apps.get_models():
|
|
||||||
GAUGE_MODELS.labels(
|
|
||||||
model_name=model._meta.model_name,
|
|
||||||
app=model._meta.app_label,
|
|
||||||
).set(model.objects.count())
|
|
||||||
|
|
||||||
|
|
||||||
@receiver(post_save)
|
@receiver(post_save)
|
||||||
# pylint: disable=unused-argument
|
# pylint: disable=unused-argument
|
||||||
def post_save_application(sender: type[Model], instance, created: bool, **_):
|
def post_save_application(sender: type[Model], instance, created: bool, **_):
|
||||||
|
|||||||
@ -26,11 +26,11 @@ from authentik.flows.planner import (
|
|||||||
from authentik.flows.views.executor import NEXT_ARG_NAME, SESSION_KEY_GET, SESSION_KEY_PLAN
|
from authentik.flows.views.executor import NEXT_ARG_NAME, SESSION_KEY_GET, SESSION_KEY_PLAN
|
||||||
from authentik.lib.utils.urls import redirect_with_qs
|
from authentik.lib.utils.urls import redirect_with_qs
|
||||||
from authentik.policies.denied import AccessDeniedResponse
|
from authentik.policies.denied import AccessDeniedResponse
|
||||||
from authentik.policies.types import PolicyResult
|
|
||||||
from authentik.policies.utils import delete_none_keys
|
from authentik.policies.utils import delete_none_keys
|
||||||
from authentik.stages.password import BACKEND_INBUILT
|
from authentik.stages.password import BACKEND_INBUILT
|
||||||
from authentik.stages.password.stage import PLAN_CONTEXT_AUTHENTICATION_BACKEND
|
from authentik.stages.password.stage import PLAN_CONTEXT_AUTHENTICATION_BACKEND
|
||||||
from authentik.stages.prompt.stage import PLAN_CONTEXT_PROMPT
|
from authentik.stages.prompt.stage import PLAN_CONTEXT_PROMPT
|
||||||
|
from authentik.stages.user_write.stage import PLAN_CONTEXT_USER_PATH
|
||||||
|
|
||||||
|
|
||||||
class Action(Enum):
|
class Action(Enum):
|
||||||
@ -165,9 +165,9 @@ class SourceFlowManager:
|
|||||||
return self.handle_enroll(connection)
|
return self.handle_enroll(connection)
|
||||||
except FlowNonApplicableException as exc:
|
except FlowNonApplicableException as exc:
|
||||||
self._logger.warning("Flow non applicable", exc=exc)
|
self._logger.warning("Flow non applicable", exc=exc)
|
||||||
return self.error_handler(exc, exc.policy_result)
|
return self.error_handler(exc)
|
||||||
# Default case, assume deny
|
# Default case, assume deny
|
||||||
error = (
|
error = Exception(
|
||||||
_(
|
_(
|
||||||
(
|
(
|
||||||
"Request to authenticate with %(source)s has been denied. Please authenticate "
|
"Request to authenticate with %(source)s has been denied. Please authenticate "
|
||||||
@ -178,14 +178,13 @@ class SourceFlowManager:
|
|||||||
)
|
)
|
||||||
return self.error_handler(error)
|
return self.error_handler(error)
|
||||||
|
|
||||||
def error_handler(
|
def error_handler(self, error: Exception) -> HttpResponse:
|
||||||
self, error: Exception, policy_result: Optional[PolicyResult] = None
|
|
||||||
) -> HttpResponse:
|
|
||||||
"""Handle any errors by returning an access denied stage"""
|
"""Handle any errors by returning an access denied stage"""
|
||||||
response = AccessDeniedResponse(self.request)
|
response = AccessDeniedResponse(self.request)
|
||||||
response.error_message = str(error)
|
response.error_message = str(error)
|
||||||
if policy_result:
|
if isinstance(error, FlowNonApplicableException):
|
||||||
response.policy_result = policy_result
|
response.policy_result = error.policy_result
|
||||||
|
response.error_message = error.messages
|
||||||
return response
|
return response
|
||||||
|
|
||||||
# pylint: disable=unused-argument
|
# pylint: disable=unused-argument
|
||||||
@ -291,5 +290,6 @@ class SourceFlowManager:
|
|||||||
connection,
|
connection,
|
||||||
**{
|
**{
|
||||||
PLAN_CONTEXT_PROMPT: delete_none_keys(self.enroll_info),
|
PLAN_CONTEXT_PROMPT: delete_none_keys(self.enroll_info),
|
||||||
|
PLAN_CONTEXT_USER_PATH: self.source.get_user_path(),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
@ -5,11 +5,14 @@
|
|||||||
|
|
||||||
{% block head_before %}
|
{% block head_before %}
|
||||||
{{ block.super }}
|
{{ block.super }}
|
||||||
|
<link rel="prefetch" href="{{ flow.background_url }}" />
|
||||||
{% if flow.compatibility_mode and not inspector %}
|
{% if flow.compatibility_mode and not inspector %}
|
||||||
<script>ShadyDOM = { force: !navigator.webdriver };</script>
|
<script>ShadyDOM = { force: !navigator.webdriver };</script>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<script>
|
<script>
|
||||||
window.authentik = {};
|
window.authentik = {
|
||||||
|
"locale": "{{ tenant.default_locale }}",
|
||||||
|
};
|
||||||
window.authentik.flow = {
|
window.authentik.flow = {
|
||||||
"layout": "{{ flow.layout }}",
|
"layout": "{{ flow.layout }}",
|
||||||
};
|
};
|
||||||
@ -19,7 +22,7 @@ window.authentik.flow = {
|
|||||||
{% block head %}
|
{% block head %}
|
||||||
<script src="{% static 'dist/flow/FlowInterface.js' %}" type="module"></script>
|
<script src="{% static 'dist/flow/FlowInterface.js' %}" type="module"></script>
|
||||||
<style>
|
<style>
|
||||||
.pf-c-background-image::before {
|
:root {
|
||||||
--ak-flow-background: url("{{ flow.background_url }}");
|
--ak-flow-background: url("{{ flow.background_url }}");
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@ -4,13 +4,19 @@
|
|||||||
{% load i18n %}
|
{% load i18n %}
|
||||||
|
|
||||||
{% block head_before %}
|
{% block head_before %}
|
||||||
|
<link rel="prefetch" href="/static/dist/assets/images/flow_background.jpg" />
|
||||||
<link rel="stylesheet" type="text/css" href="{% static 'dist/patternfly.min.css' %}">
|
<link rel="stylesheet" type="text/css" href="{% static 'dist/patternfly.min.css' %}">
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block head %}
|
{% block head %}
|
||||||
<style>
|
<style>
|
||||||
.pf-c-background-image::before {
|
:root {
|
||||||
--ak-flow-background: url("/static/dist/assets/images/flow_background.jpg");
|
--ak-flow-background: url("/static/dist/assets/images/flow_background.jpg");
|
||||||
|
--pf-c-background-image--BackgroundImage: var(--ak-flow-background);
|
||||||
|
--pf-c-background-image--BackgroundImage-2x: var(--ak-flow-background);
|
||||||
|
--pf-c-background-image--BackgroundImage--sm: var(--ak-flow-background);
|
||||||
|
--pf-c-background-image--BackgroundImage--sm-2x: var(--ak-flow-background);
|
||||||
|
--pf-c-background-image--BackgroundImage--lg: var(--ak-flow-background);
|
||||||
}
|
}
|
||||||
/* Form with user */
|
/* Form with user */
|
||||||
.form-control-static {
|
.form-control-static {
|
||||||
|
|||||||
@ -29,6 +29,7 @@ class TestApplicationsAPI(APITestCase):
|
|||||||
name="allowed",
|
name="allowed",
|
||||||
slug="allowed",
|
slug="allowed",
|
||||||
meta_launch_url="https://goauthentik.io/%(username)s",
|
meta_launch_url="https://goauthentik.io/%(username)s",
|
||||||
|
open_in_new_tab=True,
|
||||||
provider=self.provider,
|
provider=self.provider,
|
||||||
)
|
)
|
||||||
self.denied = Application.objects.create(name="denied", slug="denied")
|
self.denied = Application.objects.create(name="denied", slug="denied")
|
||||||
@ -100,6 +101,7 @@ class TestApplicationsAPI(APITestCase):
|
|||||||
},
|
},
|
||||||
"launch_url": f"https://goauthentik.io/{self.user.username}",
|
"launch_url": f"https://goauthentik.io/{self.user.username}",
|
||||||
"meta_launch_url": "https://goauthentik.io/%(username)s",
|
"meta_launch_url": "https://goauthentik.io/%(username)s",
|
||||||
|
"open_in_new_tab": True,
|
||||||
"meta_icon": None,
|
"meta_icon": None,
|
||||||
"meta_description": "",
|
"meta_description": "",
|
||||||
"meta_publisher": "",
|
"meta_publisher": "",
|
||||||
@ -148,6 +150,7 @@ class TestApplicationsAPI(APITestCase):
|
|||||||
},
|
},
|
||||||
"launch_url": f"https://goauthentik.io/{self.user.username}",
|
"launch_url": f"https://goauthentik.io/{self.user.username}",
|
||||||
"meta_launch_url": "https://goauthentik.io/%(username)s",
|
"meta_launch_url": "https://goauthentik.io/%(username)s",
|
||||||
|
"open_in_new_tab": True,
|
||||||
"meta_icon": None,
|
"meta_icon": None,
|
||||||
"meta_description": "",
|
"meta_description": "",
|
||||||
"meta_publisher": "",
|
"meta_publisher": "",
|
||||||
@ -158,6 +161,7 @@ class TestApplicationsAPI(APITestCase):
|
|||||||
"meta_description": "",
|
"meta_description": "",
|
||||||
"meta_icon": None,
|
"meta_icon": None,
|
||||||
"meta_launch_url": "",
|
"meta_launch_url": "",
|
||||||
|
"open_in_new_tab": False,
|
||||||
"meta_publisher": "",
|
"meta_publisher": "",
|
||||||
"group": "",
|
"group": "",
|
||||||
"name": "denied",
|
"name": "denied",
|
||||||
|
|||||||
@ -2,6 +2,7 @@
|
|||||||
from django.test.testcases import TestCase
|
from django.test.testcases import TestCase
|
||||||
|
|
||||||
from authentik.core.models import Group, User
|
from authentik.core.models import Group, User
|
||||||
|
from authentik.lib.generators import generate_id
|
||||||
|
|
||||||
|
|
||||||
class TestGroups(TestCase):
|
class TestGroups(TestCase):
|
||||||
@ -9,32 +10,43 @@ class TestGroups(TestCase):
|
|||||||
|
|
||||||
def test_group_membership_simple(self):
|
def test_group_membership_simple(self):
|
||||||
"""Test simple membership"""
|
"""Test simple membership"""
|
||||||
user = User.objects.create(username="user")
|
user = User.objects.create(username=generate_id())
|
||||||
user2 = User.objects.create(username="user2")
|
user2 = User.objects.create(username=generate_id())
|
||||||
group = Group.objects.create(name="group")
|
group = Group.objects.create(name=generate_id())
|
||||||
group.users.add(user)
|
group.users.add(user)
|
||||||
self.assertTrue(group.is_member(user))
|
self.assertTrue(group.is_member(user))
|
||||||
self.assertFalse(group.is_member(user2))
|
self.assertFalse(group.is_member(user2))
|
||||||
|
|
||||||
def test_group_membership_parent(self):
|
def test_group_membership_parent(self):
|
||||||
"""Test parent membership"""
|
"""Test parent membership"""
|
||||||
user = User.objects.create(username="user")
|
user = User.objects.create(username=generate_id())
|
||||||
user2 = User.objects.create(username="user2")
|
user2 = User.objects.create(username=generate_id())
|
||||||
first = Group.objects.create(name="first")
|
first = Group.objects.create(name=generate_id())
|
||||||
second = Group.objects.create(name="second", parent=first)
|
second = Group.objects.create(name=generate_id(), parent=first)
|
||||||
second.users.add(user)
|
second.users.add(user)
|
||||||
self.assertTrue(first.is_member(user))
|
self.assertTrue(first.is_member(user))
|
||||||
self.assertFalse(first.is_member(user2))
|
self.assertFalse(first.is_member(user2))
|
||||||
|
|
||||||
def test_group_membership_parent_extra(self):
|
def test_group_membership_parent_extra(self):
|
||||||
"""Test parent membership"""
|
"""Test parent membership"""
|
||||||
user = User.objects.create(username="user")
|
user = User.objects.create(username=generate_id())
|
||||||
user2 = User.objects.create(username="user2")
|
user2 = User.objects.create(username=generate_id())
|
||||||
first = Group.objects.create(name="first")
|
first = Group.objects.create(name=generate_id())
|
||||||
second = Group.objects.create(name="second", parent=first)
|
second = Group.objects.create(name=generate_id(), parent=first)
|
||||||
third = Group.objects.create(name="third", parent=second)
|
third = Group.objects.create(name=generate_id(), parent=second)
|
||||||
second.users.add(user)
|
second.users.add(user)
|
||||||
self.assertTrue(first.is_member(user))
|
self.assertTrue(first.is_member(user))
|
||||||
self.assertFalse(first.is_member(user2))
|
self.assertFalse(first.is_member(user2))
|
||||||
self.assertFalse(third.is_member(user))
|
self.assertFalse(third.is_member(user))
|
||||||
self.assertFalse(third.is_member(user2))
|
self.assertFalse(third.is_member(user2))
|
||||||
|
|
||||||
|
def test_group_membership_recursive(self):
|
||||||
|
"""Test group membership (recursive)"""
|
||||||
|
user = User.objects.create(username=generate_id())
|
||||||
|
group = Group.objects.create(name=generate_id())
|
||||||
|
group2 = Group.objects.create(name=generate_id(), parent=group)
|
||||||
|
group.users.add(user)
|
||||||
|
group.parent = group2
|
||||||
|
group.save()
|
||||||
|
self.assertTrue(group.is_member(user))
|
||||||
|
self.assertTrue(group2.is_member(user))
|
||||||
|
|||||||
@ -5,7 +5,7 @@ from rest_framework.test import APITestCase
|
|||||||
from authentik.core.models import User
|
from authentik.core.models import User
|
||||||
from authentik.core.tests.utils import create_test_admin_user, create_test_flow, create_test_tenant
|
from authentik.core.tests.utils import create_test_admin_user, create_test_flow, create_test_tenant
|
||||||
from authentik.flows.models import FlowDesignation
|
from authentik.flows.models import FlowDesignation
|
||||||
from authentik.lib.generators import generate_key
|
from authentik.lib.generators import generate_id, generate_key
|
||||||
from authentik.stages.email.models import EmailStage
|
from authentik.stages.email.models import EmailStage
|
||||||
from authentik.tenants.models import Tenant
|
from authentik.tenants.models import Tenant
|
||||||
|
|
||||||
@ -149,3 +149,65 @@ class TestUsersAPI(APITestCase):
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
self.assertEqual(response.status_code, 400)
|
self.assertEqual(response.status_code, 400)
|
||||||
|
|
||||||
|
def test_paths(self):
|
||||||
|
"""Test path"""
|
||||||
|
self.client.force_login(self.admin)
|
||||||
|
response = self.client.get(
|
||||||
|
reverse("authentik_api:user-paths"),
|
||||||
|
)
|
||||||
|
print(response.content)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertJSONEqual(response.content.decode(), {"paths": ["users"]})
|
||||||
|
|
||||||
|
def test_path_valid(self):
|
||||||
|
"""Test path"""
|
||||||
|
self.client.force_login(self.admin)
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("authentik_api:user-list"),
|
||||||
|
data={"name": generate_id(), "username": generate_id(), "groups": [], "path": "foo"},
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 201)
|
||||||
|
|
||||||
|
def test_path_invalid(self):
|
||||||
|
"""Test path (invalid)"""
|
||||||
|
self.client.force_login(self.admin)
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("authentik_api:user-list"),
|
||||||
|
data={"name": generate_id(), "username": generate_id(), "groups": [], "path": "/foo"},
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 400)
|
||||||
|
self.assertJSONEqual(
|
||||||
|
response.content.decode(), {"path": ["No leading or trailing slashes allowed."]}
|
||||||
|
)
|
||||||
|
|
||||||
|
self.client.force_login(self.admin)
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("authentik_api:user-list"),
|
||||||
|
data={"name": generate_id(), "username": generate_id(), "groups": [], "path": ""},
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 400)
|
||||||
|
self.assertJSONEqual(response.content.decode(), {"path": ["This field may not be blank."]})
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("authentik_api:user-list"),
|
||||||
|
data={"name": generate_id(), "username": generate_id(), "groups": [], "path": "foo/"},
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 400)
|
||||||
|
self.assertJSONEqual(
|
||||||
|
response.content.decode(), {"path": ["No leading or trailing slashes allowed."]}
|
||||||
|
)
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("authentik_api:user-list"),
|
||||||
|
data={
|
||||||
|
"name": generate_id(),
|
||||||
|
"username": generate_id(),
|
||||||
|
"groups": [],
|
||||||
|
"path": "fos//o",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 400)
|
||||||
|
self.assertJSONEqual(
|
||||||
|
response.content.decode(), {"path": ["No empty segments in user path allowed."]}
|
||||||
|
)
|
||||||
|
|||||||
@ -11,14 +11,13 @@ from authentik.lib.generators import generate_id
|
|||||||
from authentik.tenants.models import Tenant
|
from authentik.tenants.models import Tenant
|
||||||
|
|
||||||
|
|
||||||
def create_test_flow(designation: FlowDesignation = FlowDesignation.STAGE_CONFIGURATION) -> Flow:
|
def create_test_flow(
|
||||||
|
designation: FlowDesignation = FlowDesignation.STAGE_CONFIGURATION, **kwargs
|
||||||
|
) -> Flow:
|
||||||
"""Generate a flow that can be used for testing"""
|
"""Generate a flow that can be used for testing"""
|
||||||
uid = generate_id(10)
|
uid = generate_id(10)
|
||||||
return Flow.objects.create(
|
return Flow.objects.create(
|
||||||
name=uid,
|
name=uid, title=uid, slug=slugify(uid), designation=designation, **kwargs
|
||||||
title=uid,
|
|
||||||
slug=slugify(uid),
|
|
||||||
designation=designation,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@ -47,11 +46,11 @@ def create_test_tenant() -> Tenant:
|
|||||||
|
|
||||||
def create_test_cert() -> CertificateKeyPair:
|
def create_test_cert() -> CertificateKeyPair:
|
||||||
"""Generate a certificate for testing"""
|
"""Generate a certificate for testing"""
|
||||||
CertificateKeyPair.objects.filter(name="goauthentik.io").delete()
|
|
||||||
builder = CertificateBuilder()
|
builder = CertificateBuilder()
|
||||||
builder.common_name = "goauthentik.io"
|
builder.common_name = "goauthentik.io"
|
||||||
builder.build(
|
builder.build(
|
||||||
subject_alt_names=["goauthentik.io"],
|
subject_alt_names=["goauthentik.io"],
|
||||||
validity_days=360,
|
validity_days=360,
|
||||||
)
|
)
|
||||||
|
builder.name = generate_id()
|
||||||
return builder.save()
|
return builder.save()
|
||||||
|
|||||||
@ -14,7 +14,9 @@ from authentik.core.views.session import EndSessionView
|
|||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path(
|
path(
|
||||||
"",
|
"",
|
||||||
login_required(RedirectView.as_view(pattern_name="authentik_core:if-user")),
|
login_required(
|
||||||
|
RedirectView.as_view(pattern_name="authentik_core:if-user", query_string=True)
|
||||||
|
),
|
||||||
name="root-redirect",
|
name="root-redirect",
|
||||||
),
|
),
|
||||||
path(
|
path(
|
||||||
|
|||||||
@ -5,7 +5,10 @@ from django.shortcuts import get_object_or_404, redirect
|
|||||||
from django.views import View
|
from django.views import View
|
||||||
from structlog.stdlib import get_logger
|
from structlog.stdlib import get_logger
|
||||||
|
|
||||||
from authentik.core.middleware import SESSION_IMPERSONATE_ORIGINAL_USER, SESSION_IMPERSONATE_USER
|
from authentik.core.middleware import (
|
||||||
|
SESSION_KEY_IMPERSONATE_ORIGINAL_USER,
|
||||||
|
SESSION_KEY_IMPERSONATE_USER,
|
||||||
|
)
|
||||||
from authentik.core.models import User
|
from authentik.core.models import User
|
||||||
from authentik.events.models import Event, EventAction
|
from authentik.events.models import Event, EventAction
|
||||||
from authentik.lib.config import CONFIG
|
from authentik.lib.config import CONFIG
|
||||||
@ -27,8 +30,8 @@ class ImpersonateInitView(View):
|
|||||||
|
|
||||||
user_to_be = get_object_or_404(User, pk=user_id)
|
user_to_be = get_object_or_404(User, pk=user_id)
|
||||||
|
|
||||||
request.session[SESSION_IMPERSONATE_ORIGINAL_USER] = request.user
|
request.session[SESSION_KEY_IMPERSONATE_ORIGINAL_USER] = request.user
|
||||||
request.session[SESSION_IMPERSONATE_USER] = user_to_be
|
request.session[SESSION_KEY_IMPERSONATE_USER] = user_to_be
|
||||||
|
|
||||||
Event.new(EventAction.IMPERSONATION_STARTED).from_http(request, user_to_be)
|
Event.new(EventAction.IMPERSONATION_STARTED).from_http(request, user_to_be)
|
||||||
|
|
||||||
@ -41,16 +44,16 @@ class ImpersonateEndView(View):
|
|||||||
def get(self, request: HttpRequest) -> HttpResponse:
|
def get(self, request: HttpRequest) -> HttpResponse:
|
||||||
"""End Impersonation handler"""
|
"""End Impersonation handler"""
|
||||||
if (
|
if (
|
||||||
SESSION_IMPERSONATE_USER not in request.session
|
SESSION_KEY_IMPERSONATE_USER not in request.session
|
||||||
or SESSION_IMPERSONATE_ORIGINAL_USER not in request.session
|
or SESSION_KEY_IMPERSONATE_ORIGINAL_USER not in request.session
|
||||||
):
|
):
|
||||||
LOGGER.debug("Can't end impersonation", user=request.user)
|
LOGGER.debug("Can't end impersonation", user=request.user)
|
||||||
return redirect("authentik_core:if-user")
|
return redirect("authentik_core:if-user")
|
||||||
|
|
||||||
original_user = request.session[SESSION_IMPERSONATE_ORIGINAL_USER]
|
original_user = request.session[SESSION_KEY_IMPERSONATE_ORIGINAL_USER]
|
||||||
|
|
||||||
del request.session[SESSION_IMPERSONATE_USER]
|
del request.session[SESSION_KEY_IMPERSONATE_USER]
|
||||||
del request.session[SESSION_IMPERSONATE_ORIGINAL_USER]
|
del request.session[SESSION_KEY_IMPERSONATE_ORIGINAL_USER]
|
||||||
|
|
||||||
Event.new(EventAction.IMPERSONATION_ENDED).from_http(request, original_user)
|
Event.new(EventAction.IMPERSONATION_ENDED).from_http(request, original_user)
|
||||||
|
|
||||||
|
|||||||
@ -53,10 +53,7 @@ class CertificateBuilder:
|
|||||||
.subject_name(
|
.subject_name(
|
||||||
x509.Name(
|
x509.Name(
|
||||||
[
|
[
|
||||||
x509.NameAttribute(
|
x509.NameAttribute(NameOID.COMMON_NAME, self.common_name),
|
||||||
NameOID.COMMON_NAME,
|
|
||||||
self.common_name,
|
|
||||||
),
|
|
||||||
x509.NameAttribute(NameOID.ORGANIZATION_NAME, "authentik"),
|
x509.NameAttribute(NameOID.ORGANIZATION_NAME, "authentik"),
|
||||||
x509.NameAttribute(NameOID.ORGANIZATIONAL_UNIT_NAME, "Self-signed"),
|
x509.NameAttribute(NameOID.ORGANIZATIONAL_UNIT_NAME, "Self-signed"),
|
||||||
]
|
]
|
||||||
@ -65,10 +62,7 @@ class CertificateBuilder:
|
|||||||
.issuer_name(
|
.issuer_name(
|
||||||
x509.Name(
|
x509.Name(
|
||||||
[
|
[
|
||||||
x509.NameAttribute(
|
x509.NameAttribute(NameOID.COMMON_NAME, f"authentik {__version__}"),
|
||||||
NameOID.COMMON_NAME,
|
|
||||||
f"authentik {__version__}",
|
|
||||||
),
|
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
from django.db import migrations
|
from django.db import migrations
|
||||||
|
|
||||||
|
from authentik.lib.generators import generate_id
|
||||||
|
|
||||||
|
|
||||||
def create_self_signed(apps, schema_editor):
|
def create_self_signed(apps, schema_editor):
|
||||||
CertificateKeyPair = apps.get_model("authentik_crypto", "CertificateKeyPair")
|
CertificateKeyPair = apps.get_model("authentik_crypto", "CertificateKeyPair")
|
||||||
@ -9,7 +11,7 @@ def create_self_signed(apps, schema_editor):
|
|||||||
from authentik.crypto.builder import CertificateBuilder
|
from authentik.crypto.builder import CertificateBuilder
|
||||||
|
|
||||||
builder = CertificateBuilder()
|
builder = CertificateBuilder()
|
||||||
builder.build()
|
builder.build(subject_alt_names=[f"{generate_id()}.self-signed.goauthentik.io"])
|
||||||
CertificateKeyPair.objects.using(db_alias).create(
|
CertificateKeyPair.objects.using(db_alias).create(
|
||||||
name="authentik Self-signed Certificate",
|
name="authentik Self-signed Certificate",
|
||||||
certificate_data=builder.certificate,
|
certificate_data=builder.certificate,
|
||||||
|
|||||||
@ -26,3 +26,4 @@ class NotificationWebhookMappingViewSet(UsedByMixin, ModelViewSet):
|
|||||||
serializer_class = NotificationWebhookMappingSerializer
|
serializer_class = NotificationWebhookMappingSerializer
|
||||||
filterset_fields = ["name"]
|
filterset_fields = ["name"]
|
||||||
ordering = ["name"]
|
ordering = ["name"]
|
||||||
|
search_fields = ["name"]
|
||||||
|
|||||||
@ -32,3 +32,4 @@ class NotificationRuleViewSet(UsedByMixin, ModelViewSet):
|
|||||||
serializer_class = NotificationRuleSerializer
|
serializer_class = NotificationRuleSerializer
|
||||||
filterset_fields = ["name", "severity", "group__name"]
|
filterset_fields = ["name", "severity", "group__name"]
|
||||||
ordering = ["name"]
|
ordering = ["name"]
|
||||||
|
search_fields = ["name", "group__name"]
|
||||||
|
|||||||
@ -68,6 +68,7 @@ class NotificationTransportViewSet(UsedByMixin, ModelViewSet):
|
|||||||
queryset = NotificationTransport.objects.all()
|
queryset = NotificationTransport.objects.all()
|
||||||
serializer_class = NotificationTransportSerializer
|
serializer_class = NotificationTransportSerializer
|
||||||
filterset_fields = ["name", "mode", "webhook_url", "send_once"]
|
filterset_fields = ["name", "mode", "webhook_url", "send_once"]
|
||||||
|
search_fields = ["name", "mode", "webhook_url"]
|
||||||
ordering = ["name"]
|
ordering = ["name"]
|
||||||
|
|
||||||
@permission_required("authentik_events.change_notificationtransport")
|
@permission_required("authentik_events.change_notificationtransport")
|
||||||
|
|||||||
@ -2,6 +2,13 @@
|
|||||||
from importlib import import_module
|
from importlib import import_module
|
||||||
|
|
||||||
from django.apps import AppConfig
|
from django.apps import AppConfig
|
||||||
|
from prometheus_client import Gauge
|
||||||
|
|
||||||
|
GAUGE_TASKS = Gauge(
|
||||||
|
"authentik_system_tasks",
|
||||||
|
"System tasks and their status",
|
||||||
|
["task_name", "task_uid", "status"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class AuthentikEventsConfig(AppConfig):
|
class AuthentikEventsConfig(AppConfig):
|
||||||
|
|||||||
@ -76,11 +76,8 @@ class GeoIPReader:
|
|||||||
except (GeoIP2Error, ValueError):
|
except (GeoIP2Error, ValueError):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def city_dict(self, ip_address: str) -> Optional[GeoIPDict]:
|
def city_to_dict(self, city: City) -> GeoIPDict:
|
||||||
"""Wrapper for self.city that returns a dict"""
|
"""Convert City to dict"""
|
||||||
city = self.city(ip_address)
|
|
||||||
if not city:
|
|
||||||
return None
|
|
||||||
city_dict: GeoIPDict = {
|
city_dict: GeoIPDict = {
|
||||||
"continent": city.continent.code,
|
"continent": city.continent.code,
|
||||||
"country": city.country.iso_code,
|
"country": city.country.iso_code,
|
||||||
@ -92,5 +89,12 @@ class GeoIPReader:
|
|||||||
city_dict["city"] = city.city.name
|
city_dict["city"] = city.city.name
|
||||||
return city_dict
|
return city_dict
|
||||||
|
|
||||||
|
def city_dict(self, ip_address: str) -> Optional[GeoIPDict]:
|
||||||
|
"""Wrapper for self.city that returns a dict"""
|
||||||
|
city = self.city(ip_address)
|
||||||
|
if not city:
|
||||||
|
return None
|
||||||
|
return self.city_to_dict(city)
|
||||||
|
|
||||||
|
|
||||||
GEOIP_READER = GeoIPReader()
|
GEOIP_READER = GeoIPReader()
|
||||||
|
|||||||
@ -3,6 +3,7 @@ from functools import partial
|
|||||||
from typing import Callable
|
from typing import Callable
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
from django.contrib.sessions.models import Session
|
||||||
from django.core.exceptions import SuspiciousOperation
|
from django.core.exceptions import SuspiciousOperation
|
||||||
from django.db.models import Model
|
from django.db.models import Model
|
||||||
from django.db.models.signals import post_save, pre_delete
|
from django.db.models.signals import post_save, pre_delete
|
||||||
@ -15,6 +16,7 @@ from authentik.core.models import AuthenticatedSession, User
|
|||||||
from authentik.events.models import Event, EventAction, Notification
|
from authentik.events.models import Event, EventAction, Notification
|
||||||
from authentik.events.signals import EventNewThread
|
from authentik.events.signals import EventNewThread
|
||||||
from authentik.events.utils import model_to_dict
|
from authentik.events.utils import model_to_dict
|
||||||
|
from authentik.flows.models import FlowToken
|
||||||
from authentik.lib.sentry import before_send
|
from authentik.lib.sentry import before_send
|
||||||
from authentik.lib.utils.errors import exception_to_string
|
from authentik.lib.utils.errors import exception_to_string
|
||||||
|
|
||||||
@ -24,11 +26,13 @@ IGNORED_MODELS = [
|
|||||||
UserObjectPermission,
|
UserObjectPermission,
|
||||||
AuthenticatedSession,
|
AuthenticatedSession,
|
||||||
StaticToken,
|
StaticToken,
|
||||||
|
Session,
|
||||||
|
FlowToken,
|
||||||
]
|
]
|
||||||
if settings.DEBUG:
|
if settings.DEBUG:
|
||||||
from silk.models import Request, Response
|
from silk.models import Request, Response, SQLQuery
|
||||||
|
|
||||||
IGNORED_MODELS += [Request, Response]
|
IGNORED_MODELS += [Request, Response, SQLQuery]
|
||||||
IGNORED_MODELS = tuple(IGNORED_MODELS)
|
IGNORED_MODELS = tuple(IGNORED_MODELS)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -383,6 +383,7 @@ class Migration(migrations.Migration):
|
|||||||
models.ManyToManyField(
|
models.ManyToManyField(
|
||||||
help_text="Select which transports should be used to notify the user. If none are selected, the notification will only be shown in the authentik UI.",
|
help_text="Select which transports should be used to notify the user. If none are selected, the notification will only be shown in the authentik UI.",
|
||||||
to="authentik_events.NotificationTransport",
|
to="authentik_events.NotificationTransport",
|
||||||
|
blank=True,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@ -0,0 +1,50 @@
|
|||||||
|
# Generated by Django 4.0.4 on 2022-05-30 18:08
|
||||||
|
from django.apps.registry import Apps
|
||||||
|
from django.db import migrations, models
|
||||||
|
from django.db.backends.base.schema import BaseDatabaseSchemaEditor
|
||||||
|
|
||||||
|
from authentik.events.models import TransportMode
|
||||||
|
|
||||||
|
|
||||||
|
def notify_local_transport(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
|
||||||
|
db_alias = schema_editor.connection.alias
|
||||||
|
NotificationTransport = apps.get_model("authentik_events", "NotificationTransport")
|
||||||
|
NotificationRule = apps.get_model("authentik_events", "NotificationRule")
|
||||||
|
|
||||||
|
local_transport, _ = NotificationTransport.objects.using(db_alias).update_or_create(
|
||||||
|
name="default-local-transport",
|
||||||
|
defaults={"mode": TransportMode.LOCAL},
|
||||||
|
)
|
||||||
|
|
||||||
|
for trigger in NotificationRule.objects.using(db_alias).filter(
|
||||||
|
name__in=[
|
||||||
|
"default-notify-configuration-error",
|
||||||
|
"default-notify-exception",
|
||||||
|
"default-notify-update",
|
||||||
|
]
|
||||||
|
):
|
||||||
|
trigger.transports.add(local_transport)
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("authentik_events", "0001_squashed_0019_alter_notificationtransport_webhook_url"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="notificationtransport",
|
||||||
|
name="mode",
|
||||||
|
field=models.TextField(
|
||||||
|
choices=[
|
||||||
|
("local", "authentik inbuilt notifications"),
|
||||||
|
("webhook", "Generic Webhook"),
|
||||||
|
("webhook_slack", "Slack Webhook (Slack/Discord)"),
|
||||||
|
("email", "Email"),
|
||||||
|
],
|
||||||
|
default="local",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.RunPython(notify_local_transport),
|
||||||
|
]
|
||||||
@ -23,7 +23,10 @@ from requests import RequestException
|
|||||||
from structlog.stdlib import get_logger
|
from structlog.stdlib import get_logger
|
||||||
|
|
||||||
from authentik import __version__
|
from authentik import __version__
|
||||||
from authentik.core.middleware import SESSION_IMPERSONATE_ORIGINAL_USER, SESSION_IMPERSONATE_USER
|
from authentik.core.middleware import (
|
||||||
|
SESSION_KEY_IMPERSONATE_ORIGINAL_USER,
|
||||||
|
SESSION_KEY_IMPERSONATE_USER,
|
||||||
|
)
|
||||||
from authentik.core.models import ExpiringModel, Group, PropertyMapping, User
|
from authentik.core.models import ExpiringModel, Group, PropertyMapping, User
|
||||||
from authentik.events.geo import GEOIP_READER
|
from authentik.events.geo import GEOIP_READER
|
||||||
from authentik.events.utils import cleanse_dict, get_user, model_to_dict, sanitize_dict
|
from authentik.events.utils import cleanse_dict, get_user, model_to_dict, sanitize_dict
|
||||||
@ -233,15 +236,15 @@ class Event(ExpiringModel):
|
|||||||
if hasattr(request, "user"):
|
if hasattr(request, "user"):
|
||||||
original_user = None
|
original_user = None
|
||||||
if hasattr(request, "session"):
|
if hasattr(request, "session"):
|
||||||
original_user = request.session.get(SESSION_IMPERSONATE_ORIGINAL_USER, None)
|
original_user = request.session.get(SESSION_KEY_IMPERSONATE_ORIGINAL_USER, None)
|
||||||
self.user = get_user(request.user, original_user)
|
self.user = get_user(request.user, original_user)
|
||||||
if user:
|
if user:
|
||||||
self.user = get_user(user)
|
self.user = get_user(user)
|
||||||
# Check if we're currently impersonating, and add that user
|
# Check if we're currently impersonating, and add that user
|
||||||
if hasattr(request, "session"):
|
if hasattr(request, "session"):
|
||||||
if SESSION_IMPERSONATE_ORIGINAL_USER in request.session:
|
if SESSION_KEY_IMPERSONATE_ORIGINAL_USER in request.session:
|
||||||
self.user = get_user(request.session[SESSION_IMPERSONATE_ORIGINAL_USER])
|
self.user = get_user(request.session[SESSION_KEY_IMPERSONATE_ORIGINAL_USER])
|
||||||
self.user["on_behalf_of"] = get_user(request.session[SESSION_IMPERSONATE_USER])
|
self.user["on_behalf_of"] = get_user(request.session[SESSION_KEY_IMPERSONATE_USER])
|
||||||
# User 255.255.255.255 as fallback if IP cannot be determined
|
# User 255.255.255.255 as fallback if IP cannot be determined
|
||||||
self.client_ip = get_client_ip(request)
|
self.client_ip = get_client_ip(request)
|
||||||
# Apply GeoIP Data, when enabled
|
# Apply GeoIP Data, when enabled
|
||||||
@ -289,6 +292,7 @@ class Event(ExpiringModel):
|
|||||||
class TransportMode(models.TextChoices):
|
class TransportMode(models.TextChoices):
|
||||||
"""Modes that a notification transport can send a notification"""
|
"""Modes that a notification transport can send a notification"""
|
||||||
|
|
||||||
|
LOCAL = "local", _("authentik inbuilt notifications")
|
||||||
WEBHOOK = "webhook", _("Generic Webhook")
|
WEBHOOK = "webhook", _("Generic Webhook")
|
||||||
WEBHOOK_SLACK = "webhook_slack", _("Slack Webhook (Slack/Discord)")
|
WEBHOOK_SLACK = "webhook_slack", _("Slack Webhook (Slack/Discord)")
|
||||||
EMAIL = "email", _("Email")
|
EMAIL = "email", _("Email")
|
||||||
@ -300,7 +304,7 @@ class NotificationTransport(models.Model):
|
|||||||
uuid = models.UUIDField(primary_key=True, editable=False, default=uuid4)
|
uuid = models.UUIDField(primary_key=True, editable=False, default=uuid4)
|
||||||
|
|
||||||
name = models.TextField(unique=True)
|
name = models.TextField(unique=True)
|
||||||
mode = models.TextField(choices=TransportMode.choices)
|
mode = models.TextField(choices=TransportMode.choices, default=TransportMode.LOCAL)
|
||||||
|
|
||||||
webhook_url = models.TextField(blank=True, validators=[DomainlessURLValidator()])
|
webhook_url = models.TextField(blank=True, validators=[DomainlessURLValidator()])
|
||||||
webhook_mapping = models.ForeignKey(
|
webhook_mapping = models.ForeignKey(
|
||||||
@ -315,6 +319,8 @@ class NotificationTransport(models.Model):
|
|||||||
|
|
||||||
def send(self, notification: "Notification") -> list[str]:
|
def send(self, notification: "Notification") -> list[str]:
|
||||||
"""Send notification to user, called from async task"""
|
"""Send notification to user, called from async task"""
|
||||||
|
if self.mode == TransportMode.LOCAL:
|
||||||
|
return self.send_local(notification)
|
||||||
if self.mode == TransportMode.WEBHOOK:
|
if self.mode == TransportMode.WEBHOOK:
|
||||||
return self.send_webhook(notification)
|
return self.send_webhook(notification)
|
||||||
if self.mode == TransportMode.WEBHOOK_SLACK:
|
if self.mode == TransportMode.WEBHOOK_SLACK:
|
||||||
@ -323,6 +329,17 @@ class NotificationTransport(models.Model):
|
|||||||
return self.send_email(notification)
|
return self.send_email(notification)
|
||||||
raise ValueError(f"Invalid mode {self.mode} set")
|
raise ValueError(f"Invalid mode {self.mode} set")
|
||||||
|
|
||||||
|
def send_local(self, notification: "Notification") -> list[str]:
|
||||||
|
"""Local notification delivery"""
|
||||||
|
if self.webhook_mapping:
|
||||||
|
self.webhook_mapping.evaluate(
|
||||||
|
user=notification.user,
|
||||||
|
request=None,
|
||||||
|
notification=notification,
|
||||||
|
)
|
||||||
|
notification.save()
|
||||||
|
return []
|
||||||
|
|
||||||
def send_webhook(self, notification: "Notification") -> list[str]:
|
def send_webhook(self, notification: "Notification") -> list[str]:
|
||||||
"""Send notification to generic webhook"""
|
"""Send notification to generic webhook"""
|
||||||
default_body = {
|
default_body = {
|
||||||
@ -481,6 +498,7 @@ class NotificationRule(PolicyBindingModel):
|
|||||||
"selected, the notification will only be shown in the authentik UI."
|
"selected, the notification will only be shown in the authentik UI."
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
|
blank=True,
|
||||||
)
|
)
|
||||||
severity = models.TextField(
|
severity = models.TextField(
|
||||||
choices=NotificationSeverity.choices,
|
choices=NotificationSeverity.choices,
|
||||||
|
|||||||
@ -8,18 +8,12 @@ from typing import Any, Optional
|
|||||||
from celery import Task
|
from celery import Task
|
||||||
from django.core.cache import cache
|
from django.core.cache import cache
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from prometheus_client import Gauge
|
|
||||||
from structlog.stdlib import get_logger
|
from structlog.stdlib import get_logger
|
||||||
|
|
||||||
|
from authentik.events.apps import GAUGE_TASKS
|
||||||
from authentik.events.models import Event, EventAction
|
from authentik.events.models import Event, EventAction
|
||||||
from authentik.lib.utils.errors import exception_to_string
|
from authentik.lib.utils.errors import exception_to_string
|
||||||
|
|
||||||
GAUGE_TASKS = Gauge(
|
|
||||||
"authentik_system_tasks",
|
|
||||||
"System tasks and their status",
|
|
||||||
["task_name", "task_uid", "status"],
|
|
||||||
)
|
|
||||||
|
|
||||||
LOGGER = get_logger()
|
LOGGER = get_logger()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -2,15 +2,16 @@
|
|||||||
from threading import Thread
|
from threading import Thread
|
||||||
from typing import Any, Optional
|
from typing import Any, Optional
|
||||||
|
|
||||||
from django.contrib.auth.signals import user_logged_in, user_logged_out, user_login_failed
|
from django.contrib.auth.signals import user_logged_in, user_logged_out
|
||||||
from django.db.models.signals import post_save, pre_delete
|
from django.db.models.signals import post_save, pre_delete
|
||||||
from django.dispatch import receiver
|
from django.dispatch import receiver
|
||||||
from django.http import HttpRequest
|
from django.http import HttpRequest
|
||||||
|
|
||||||
from authentik.core.models import User
|
from authentik.core.models import User
|
||||||
from authentik.core.signals import password_changed
|
from authentik.core.signals import login_failed, password_changed
|
||||||
from authentik.events.models import Event, EventAction
|
from authentik.events.models import Event, EventAction
|
||||||
from authentik.events.tasks import event_notification_handler, gdpr_cleanup
|
from authentik.events.tasks import event_notification_handler, gdpr_cleanup
|
||||||
|
from authentik.flows.models import Stage
|
||||||
from authentik.flows.planner import PLAN_CONTEXT_SOURCE, FlowPlan
|
from authentik.flows.planner import PLAN_CONTEXT_SOURCE, FlowPlan
|
||||||
from authentik.flows.views.executor import SESSION_KEY_PLAN
|
from authentik.flows.views.executor import SESSION_KEY_PLAN
|
||||||
from authentik.stages.invitation.models import Invitation
|
from authentik.stages.invitation.models import Invitation
|
||||||
@ -77,11 +78,18 @@ def on_user_write(sender, request: HttpRequest, user: User, data: dict[str, Any]
|
|||||||
thread.run()
|
thread.run()
|
||||||
|
|
||||||
|
|
||||||
@receiver(user_login_failed)
|
@receiver(login_failed)
|
||||||
# pylint: disable=unused-argument
|
# pylint: disable=unused-argument
|
||||||
def on_user_login_failed(sender, credentials: dict[str, str], request: HttpRequest, **_):
|
def on_login_failed(
|
||||||
"""Failed Login"""
|
signal,
|
||||||
thread = EventNewThread(EventAction.LOGIN_FAILED, request, **credentials)
|
sender,
|
||||||
|
credentials: dict[str, str],
|
||||||
|
request: HttpRequest,
|
||||||
|
stage: Optional[Stage] = None,
|
||||||
|
**kwargs,
|
||||||
|
):
|
||||||
|
"""Failed Login, authentik custom event"""
|
||||||
|
thread = EventNewThread(EventAction.LOGIN_FAILED, request, **credentials, stage=stage, **kwargs)
|
||||||
thread.run()
|
thread.run()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -1,8 +1,11 @@
|
|||||||
"""Event notification tasks"""
|
"""Event notification tasks"""
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
from django.db.models.query_utils import Q
|
from django.db.models.query_utils import Q
|
||||||
from guardian.shortcuts import get_anonymous_user
|
from guardian.shortcuts import get_anonymous_user
|
||||||
from structlog.stdlib import get_logger
|
from structlog.stdlib import get_logger
|
||||||
|
|
||||||
|
from authentik.core.exceptions import PropertyMappingExpressionException
|
||||||
from authentik.core.models import User
|
from authentik.core.models import User
|
||||||
from authentik.events.models import (
|
from authentik.events.models import (
|
||||||
Event,
|
Event,
|
||||||
@ -39,10 +42,9 @@ def event_trigger_handler(event_uuid: str, trigger_name: str):
|
|||||||
LOGGER.warning("event doesn't exist yet or anymore", event_uuid=event_uuid)
|
LOGGER.warning("event doesn't exist yet or anymore", event_uuid=event_uuid)
|
||||||
return
|
return
|
||||||
event: Event = events.first()
|
event: Event = events.first()
|
||||||
triggers: NotificationRule = NotificationRule.objects.filter(name=trigger_name)
|
trigger: Optional[NotificationRule] = NotificationRule.objects.filter(name=trigger_name).first()
|
||||||
if not triggers.exists():
|
if not trigger:
|
||||||
return
|
return
|
||||||
trigger = triggers.first()
|
|
||||||
|
|
||||||
if "policy_uuid" in event.context:
|
if "policy_uuid" in event.context:
|
||||||
policy_uuid = event.context["policy_uuid"]
|
policy_uuid = event.context["policy_uuid"]
|
||||||
@ -81,11 +83,14 @@ def event_trigger_handler(event_uuid: str, trigger_name: str):
|
|||||||
for transport in trigger.transports.all():
|
for transport in trigger.transports.all():
|
||||||
for user in trigger.group.users.all():
|
for user in trigger.group.users.all():
|
||||||
LOGGER.debug("created notification")
|
LOGGER.debug("created notification")
|
||||||
notification = Notification.objects.create(
|
|
||||||
severity=trigger.severity, body=event.summary, event=event, user=user
|
|
||||||
)
|
|
||||||
notification_transport.apply_async(
|
notification_transport.apply_async(
|
||||||
args=[notification.pk, transport.pk], queue="authentik_events"
|
args=[
|
||||||
|
transport.pk,
|
||||||
|
str(event.pk),
|
||||||
|
user.pk,
|
||||||
|
str(trigger.pk),
|
||||||
|
],
|
||||||
|
queue="authentik_events",
|
||||||
)
|
)
|
||||||
if transport.send_once:
|
if transport.send_once:
|
||||||
break
|
break
|
||||||
@ -97,19 +102,30 @@ def event_trigger_handler(event_uuid: str, trigger_name: str):
|
|||||||
retry_backoff=True,
|
retry_backoff=True,
|
||||||
base=MonitoredTask,
|
base=MonitoredTask,
|
||||||
)
|
)
|
||||||
def notification_transport(self: MonitoredTask, notification_pk: int, transport_pk: int):
|
def notification_transport(
|
||||||
|
self: MonitoredTask, transport_pk: int, event_pk: str, user_pk: int, trigger_pk: str
|
||||||
|
):
|
||||||
"""Send notification over specified transport"""
|
"""Send notification over specified transport"""
|
||||||
self.save_on_success = False
|
self.save_on_success = False
|
||||||
try:
|
try:
|
||||||
notification: Notification = Notification.objects.filter(pk=notification_pk).first()
|
event = Event.objects.filter(pk=event_pk).first()
|
||||||
if not notification:
|
if not event:
|
||||||
return
|
return
|
||||||
|
user = User.objects.filter(pk=user_pk).first()
|
||||||
|
if not user:
|
||||||
|
return
|
||||||
|
trigger = NotificationRule.objects.filter(pk=trigger_pk).first()
|
||||||
|
if not trigger:
|
||||||
|
return
|
||||||
|
notification = Notification(
|
||||||
|
severity=trigger.severity, body=event.summary, event=event, user=user
|
||||||
|
)
|
||||||
transport = NotificationTransport.objects.filter(pk=transport_pk).first()
|
transport = NotificationTransport.objects.filter(pk=transport_pk).first()
|
||||||
if not transport:
|
if not transport:
|
||||||
return
|
return
|
||||||
transport.send(notification)
|
transport.send(notification)
|
||||||
self.set_status(TaskResult(TaskResultStatus.SUCCESSFUL))
|
self.set_status(TaskResult(TaskResultStatus.SUCCESSFUL))
|
||||||
except NotificationTransportError as exc:
|
except (NotificationTransportError, PropertyMappingExpressionException) as exc:
|
||||||
self.set_status(TaskResult(TaskResultStatus.ERROR).with_error(exc))
|
self.set_status(TaskResult(TaskResultStatus.ERROR).with_error(exc))
|
||||||
raise exc
|
raise exc
|
||||||
|
|
||||||
|
|||||||
@ -11,7 +11,10 @@ from authentik.events.models import (
|
|||||||
Notification,
|
Notification,
|
||||||
NotificationRule,
|
NotificationRule,
|
||||||
NotificationTransport,
|
NotificationTransport,
|
||||||
|
NotificationWebhookMapping,
|
||||||
|
TransportMode,
|
||||||
)
|
)
|
||||||
|
from authentik.lib.generators import generate_id
|
||||||
from authentik.policies.event_matcher.models import EventMatcherPolicy
|
from authentik.policies.event_matcher.models import EventMatcherPolicy
|
||||||
from authentik.policies.exceptions import PolicyException
|
from authentik.policies.exceptions import PolicyException
|
||||||
from authentik.policies.models import PolicyBinding
|
from authentik.policies.models import PolicyBinding
|
||||||
@ -105,4 +108,26 @@ class TestEventsNotifications(TestCase):
|
|||||||
execute_mock = MagicMock()
|
execute_mock = MagicMock()
|
||||||
with patch("authentik.events.models.NotificationTransport.send", execute_mock):
|
with patch("authentik.events.models.NotificationTransport.send", execute_mock):
|
||||||
Event.new(EventAction.CUSTOM_PREFIX).save()
|
Event.new(EventAction.CUSTOM_PREFIX).save()
|
||||||
self.assertEqual(Notification.objects.count(), 1)
|
self.assertEqual(execute_mock.call_count, 1)
|
||||||
|
|
||||||
|
def test_transport_mapping(self):
|
||||||
|
"""Test transport mapping"""
|
||||||
|
mapping = NotificationWebhookMapping.objects.create(
|
||||||
|
name=generate_id(),
|
||||||
|
expression="""notification.body = 'foo'""",
|
||||||
|
)
|
||||||
|
|
||||||
|
transport = NotificationTransport.objects.create(
|
||||||
|
name="transport", webhook_mapping=mapping, mode=TransportMode.LOCAL
|
||||||
|
)
|
||||||
|
NotificationRule.objects.filter(name__startswith="default").delete()
|
||||||
|
trigger = NotificationRule.objects.create(name="trigger", group=self.group)
|
||||||
|
trigger.transports.add(transport)
|
||||||
|
matcher = EventMatcherPolicy.objects.create(
|
||||||
|
name="matcher", action=EventAction.CUSTOM_PREFIX
|
||||||
|
)
|
||||||
|
PolicyBinding.objects.create(target=trigger, policy=matcher, order=0)
|
||||||
|
|
||||||
|
Notification.objects.all().delete()
|
||||||
|
Event.new(EventAction.CUSTOM_PREFIX).save()
|
||||||
|
self.assertEqual(Notification.objects.first().body, "foo")
|
||||||
|
|||||||
@ -10,9 +10,11 @@ from django.db import models
|
|||||||
from django.db.models.base import Model
|
from django.db.models.base import Model
|
||||||
from django.http.request import HttpRequest
|
from django.http.request import HttpRequest
|
||||||
from django.views.debug import SafeExceptionReporterFilter
|
from django.views.debug import SafeExceptionReporterFilter
|
||||||
|
from geoip2.models import City
|
||||||
from guardian.utils import get_anonymous_user
|
from guardian.utils import get_anonymous_user
|
||||||
|
|
||||||
from authentik.core.models import User
|
from authentik.core.models import User
|
||||||
|
from authentik.events.geo import GEOIP_READER
|
||||||
from authentik.policies.types import PolicyRequest
|
from authentik.policies.types import PolicyRequest
|
||||||
|
|
||||||
# Special keys which are *not* cleaned, even when the default filter
|
# Special keys which are *not* cleaned, even when the default filter
|
||||||
@ -93,6 +95,8 @@ def sanitize_dict(source: dict[Any, Any]) -> dict[Any, Any]:
|
|||||||
final_dict[key] = value.hex
|
final_dict[key] = value.hex
|
||||||
elif isinstance(value, (HttpRequest, WSGIRequest)):
|
elif isinstance(value, (HttpRequest, WSGIRequest)):
|
||||||
continue
|
continue
|
||||||
|
elif isinstance(value, City):
|
||||||
|
final_dict[key] = GEOIP_READER.city_to_dict(value)
|
||||||
elif isinstance(value, type):
|
elif isinstance(value, type):
|
||||||
final_dict[key] = {
|
final_dict[key] = {
|
||||||
"type": value.__name__,
|
"type": value.__name__,
|
||||||
|
|||||||
@ -35,3 +35,4 @@ class FlowStageBindingViewSet(UsedByMixin, ModelViewSet):
|
|||||||
queryset = FlowStageBinding.objects.all()
|
queryset = FlowStageBinding.objects.all()
|
||||||
serializer_class = FlowStageBindingSerializer
|
serializer_class = FlowStageBindingSerializer
|
||||||
filterset_fields = "__all__"
|
filterset_fields = "__all__"
|
||||||
|
search_fields = ["stage__name"]
|
||||||
|
|||||||
@ -73,6 +73,7 @@ class FlowSerializer(ModelSerializer):
|
|||||||
"compatibility_mode",
|
"compatibility_mode",
|
||||||
"export_url",
|
"export_url",
|
||||||
"layout",
|
"layout",
|
||||||
|
"denied_action",
|
||||||
]
|
]
|
||||||
extra_kwargs = {
|
extra_kwargs = {
|
||||||
"background": {"read_only": True},
|
"background": {"read_only": True},
|
||||||
@ -110,8 +111,8 @@ class FlowViewSet(UsedByMixin, ModelViewSet):
|
|||||||
serializer_class = FlowSerializer
|
serializer_class = FlowSerializer
|
||||||
lookup_field = "slug"
|
lookup_field = "slug"
|
||||||
ordering = ["slug", "name"]
|
ordering = ["slug", "name"]
|
||||||
search_fields = ["name", "slug", "designation", "title"]
|
search_fields = ["name", "slug", "designation", "title", "denied_action"]
|
||||||
filterset_fields = ["flow_uuid", "name", "slug", "designation"]
|
filterset_fields = ["flow_uuid", "name", "slug", "designation", "denied_action"]
|
||||||
|
|
||||||
@permission_required(None, ["authentik_flows.view_flow_cache"])
|
@permission_required(None, ["authentik_flows.view_flow_cache"])
|
||||||
@extend_schema(responses={200: CacheSerializer(many=False)})
|
@extend_schema(responses={200: CacheSerializer(many=False)})
|
||||||
@ -371,7 +372,7 @@ class FlowViewSet(UsedByMixin, ModelViewSet):
|
|||||||
request,
|
request,
|
||||||
_(
|
_(
|
||||||
"Flow not applicable to current user/request: %(messages)s"
|
"Flow not applicable to current user/request: %(messages)s"
|
||||||
% {"messages": str(exc)}
|
% {"messages": exc.messages}
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
return Response(
|
return Response(
|
||||||
|
|||||||
@ -3,9 +3,20 @@ from importlib import import_module
|
|||||||
|
|
||||||
from django.apps import AppConfig
|
from django.apps import AppConfig
|
||||||
from django.db.utils import ProgrammingError
|
from django.db.utils import ProgrammingError
|
||||||
|
from prometheus_client import Gauge, Histogram
|
||||||
|
|
||||||
from authentik.lib.utils.reflection import all_subclasses
|
from authentik.lib.utils.reflection import all_subclasses
|
||||||
|
|
||||||
|
GAUGE_FLOWS_CACHED = Gauge(
|
||||||
|
"authentik_flows_cached",
|
||||||
|
"Cached flows",
|
||||||
|
)
|
||||||
|
HIST_FLOWS_PLAN_TIME = Histogram(
|
||||||
|
"authentik_flows_plan_time",
|
||||||
|
"Duration to build a plan for a flow",
|
||||||
|
["flow_slug"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class AuthentikFlowsConfig(AppConfig):
|
class AuthentikFlowsConfig(AppConfig):
|
||||||
"""authentik flows app config"""
|
"""authentik flows app config"""
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
"""Challenge helpers"""
|
"""Challenge helpers"""
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from typing import TYPE_CHECKING, Optional
|
from typing import TYPE_CHECKING, Optional, TypedDict
|
||||||
|
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.http import JsonResponse
|
from django.http import JsonResponse
|
||||||
@ -95,6 +95,13 @@ class AccessDeniedChallenge(WithUserInfoChallenge):
|
|||||||
component = CharField(default="ak-stage-access-denied")
|
component = CharField(default="ak-stage-access-denied")
|
||||||
|
|
||||||
|
|
||||||
|
class PermissionDict(TypedDict):
|
||||||
|
"""Consent Permission"""
|
||||||
|
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
|
||||||
|
|
||||||
class PermissionSerializer(PassiveSerializer):
|
class PermissionSerializer(PassiveSerializer):
|
||||||
"""Permission used for consent"""
|
"""Permission used for consent"""
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
"""flow exceptions"""
|
"""flow exceptions"""
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
from authentik.lib.sentry import SentryIgnoredException
|
from authentik.lib.sentry import SentryIgnoredException
|
||||||
from authentik.policies.types import PolicyResult
|
from authentik.policies.types import PolicyResult
|
||||||
@ -9,6 +10,13 @@ class FlowNonApplicableException(SentryIgnoredException):
|
|||||||
|
|
||||||
policy_result: PolicyResult
|
policy_result: PolicyResult
|
||||||
|
|
||||||
|
@property
|
||||||
|
def messages(self) -> str:
|
||||||
|
"""Get messages from policy result, fallback to generic reason"""
|
||||||
|
if len(self.policy_result.messages) < 1:
|
||||||
|
return _("Flow does not apply to current user (denied by policy).")
|
||||||
|
return "\n".join(self.policy_result.messages)
|
||||||
|
|
||||||
|
|
||||||
class EmptyFlowException(SentryIgnoredException):
|
class EmptyFlowException(SentryIgnoredException):
|
||||||
"""Flow has no stages."""
|
"""Flow has no stages."""
|
||||||
|
|||||||
@ -94,9 +94,9 @@ class Command(BaseCommand): # pragma: no cover
|
|||||||
|
|
||||||
def output_overview(self, values):
|
def output_overview(self, values):
|
||||||
"""Output results human readable"""
|
"""Output results human readable"""
|
||||||
total_max: int = max([max(inner) for inner in values])
|
total_max: int = max(max(inner) for inner in values)
|
||||||
total_min: int = min([min(inner) for inner in values])
|
total_min: int = min(min(inner) for inner in values)
|
||||||
total_avg = sum([sum(inner) for inner in values]) / sum([len(inner) for inner in values])
|
total_avg = sum(sum(inner) for inner in values) / sum(len(inner) for inner in values)
|
||||||
|
|
||||||
print(f"Version: {__version__}")
|
print(f"Version: {__version__}")
|
||||||
print(f"Processes: {len(values)}")
|
print(f"Processes: {len(values)}")
|
||||||
|
|||||||
@ -47,7 +47,8 @@ class ReevaluateMarker(StageMarker):
|
|||||||
from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER
|
from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER
|
||||||
|
|
||||||
LOGGER.debug(
|
LOGGER.debug(
|
||||||
"f(plan_inst)[re-eval marker]: running re-evaluation",
|
"f(plan_inst): running re-evaluation",
|
||||||
|
marker="ReevaluateMarker",
|
||||||
binding=binding,
|
binding=binding,
|
||||||
policy_binding=self.binding,
|
policy_binding=self.binding,
|
||||||
)
|
)
|
||||||
@ -56,13 +57,15 @@ class ReevaluateMarker(StageMarker):
|
|||||||
)
|
)
|
||||||
engine.use_cache = False
|
engine.use_cache = False
|
||||||
engine.request.set_http_request(http_request)
|
engine.request.set_http_request(http_request)
|
||||||
engine.request.context = plan.context
|
engine.request.context["flow_plan"] = plan
|
||||||
|
engine.request.context.update(plan.context)
|
||||||
engine.build()
|
engine.build()
|
||||||
result = engine.result
|
result = engine.result
|
||||||
if result.passing:
|
if result.passing:
|
||||||
return binding
|
return binding
|
||||||
LOGGER.warning(
|
LOGGER.warning(
|
||||||
"f(plan_inst)[re-eval marker]: binding failed re-evaluation",
|
"f(plan_inst): binding failed re-evaluation",
|
||||||
|
marker="ReevaluateMarker",
|
||||||
binding=binding,
|
binding=binding,
|
||||||
messages=result.messages,
|
messages=result.messages,
|
||||||
)
|
)
|
||||||
|
|||||||
@ -14,7 +14,7 @@ return not akadmin.has_usable_password()"""
|
|||||||
PREFILL_POLICY_EXPRESSION = """# This policy sets the user for the currently running flow
|
PREFILL_POLICY_EXPRESSION = """# This policy sets the user for the currently running flow
|
||||||
# by injecting "pending_user"
|
# by injecting "pending_user"
|
||||||
akadmin = ak_user_by(username="akadmin")
|
akadmin = ak_user_by(username="akadmin")
|
||||||
context["pending_user"] = akadmin
|
context["flow_plan"].context["pending_user"] = akadmin
|
||||||
return True"""
|
return True"""
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
26
authentik/flows/migrations/0023_flow_denied_action.py
Normal file
26
authentik/flows/migrations/0023_flow_denied_action.py
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
# Generated by Django 4.0.5 on 2022-07-02 12:42
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("authentik_flows", "0022_flow_layout"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="flow",
|
||||||
|
name="denied_action",
|
||||||
|
field=models.TextField(
|
||||||
|
choices=[
|
||||||
|
("message_continue", "Message Continue"),
|
||||||
|
("message", "Message"),
|
||||||
|
("continue", "Continue"),
|
||||||
|
],
|
||||||
|
default="message_continue",
|
||||||
|
help_text="Configure what should happen when a flow denies access to a user.",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -5,7 +5,6 @@ from typing import TYPE_CHECKING, Optional
|
|||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.http import HttpRequest
|
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from model_utils.managers import InheritanceManager
|
from model_utils.managers import InheritanceManager
|
||||||
from rest_framework.serializers import BaseSerializer
|
from rest_framework.serializers import BaseSerializer
|
||||||
@ -40,6 +39,14 @@ class InvalidResponseAction(models.TextChoices):
|
|||||||
RESTART_WITH_CONTEXT = "restart_with_context"
|
RESTART_WITH_CONTEXT = "restart_with_context"
|
||||||
|
|
||||||
|
|
||||||
|
class FlowDeniedAction(models.TextChoices):
|
||||||
|
"""Configure what response is given to denied flow executions"""
|
||||||
|
|
||||||
|
MESSAGE_CONTINUE = "message_continue"
|
||||||
|
MESSAGE = "message"
|
||||||
|
CONTINUE = "continue"
|
||||||
|
|
||||||
|
|
||||||
class FlowDesignation(models.TextChoices):
|
class FlowDesignation(models.TextChoices):
|
||||||
"""Designation of what a Flow should be used for. At a later point, this
|
"""Designation of what a Flow should be used for. At a later point, this
|
||||||
should be replaced by a database entry."""
|
should be replaced by a database entry."""
|
||||||
@ -87,13 +94,15 @@ class Stage(SerializerModel):
|
|||||||
return f"Stage {self.name}"
|
return f"Stage {self.name}"
|
||||||
|
|
||||||
|
|
||||||
def in_memory_stage(view: type["StageView"]) -> Stage:
|
def in_memory_stage(view: type["StageView"], **kwargs) -> Stage:
|
||||||
"""Creates an in-memory stage instance, based on a `view` as view."""
|
"""Creates an in-memory stage instance, based on a `view` as view."""
|
||||||
stage = Stage()
|
stage = Stage()
|
||||||
# Because we can't pickle a locally generated function,
|
# Because we can't pickle a locally generated function,
|
||||||
# we set the view as a separate property and reference a generic function
|
# we set the view as a separate property and reference a generic function
|
||||||
# that returns that member
|
# that returns that member
|
||||||
setattr(stage, "__in_memory_type", view)
|
setattr(stage, "__in_memory_type", view)
|
||||||
|
for key, value in kwargs.items():
|
||||||
|
setattr(stage, key, value)
|
||||||
return stage
|
return stage
|
||||||
|
|
||||||
|
|
||||||
@ -137,6 +146,12 @@ class Flow(SerializerModel, PolicyBindingModel):
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
denied_action = models.TextField(
|
||||||
|
choices=FlowDeniedAction.choices,
|
||||||
|
default=FlowDeniedAction.MESSAGE_CONTINUE,
|
||||||
|
help_text=_("Configure what should happen when a flow denies access to a user."),
|
||||||
|
)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def background_url(self) -> str:
|
def background_url(self) -> str:
|
||||||
"""Get the URL to the background image. If the name is /static or starts with http
|
"""Get the URL to the background image. If the name is /static or starts with http
|
||||||
@ -155,23 +170,6 @@ class Flow(SerializerModel, PolicyBindingModel):
|
|||||||
|
|
||||||
return FlowSerializer
|
return FlowSerializer
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def with_policy(request: HttpRequest, **flow_filter) -> Optional["Flow"]:
|
|
||||||
"""Get a Flow by `**flow_filter` and check if the request from `request` can access it."""
|
|
||||||
from authentik.policies.engine import PolicyEngine
|
|
||||||
|
|
||||||
flows = Flow.objects.filter(**flow_filter).order_by("slug")
|
|
||||||
for flow in flows:
|
|
||||||
engine = PolicyEngine(flow, request.user, request)
|
|
||||||
engine.build()
|
|
||||||
result = engine.result
|
|
||||||
if result.passing:
|
|
||||||
LOGGER.debug("with_policy: flow passing", flow=flow)
|
|
||||||
return flow
|
|
||||||
LOGGER.warning("with_policy: flow not passing", flow=flow, messages=result.messages)
|
|
||||||
LOGGER.debug("with_policy: no flow found", filters=flow_filter)
|
|
||||||
return None
|
|
||||||
|
|
||||||
def __str__(self) -> str:
|
def __str__(self) -> str:
|
||||||
return f"Flow {self.name} ({self.slug})"
|
return f"Flow {self.name} ({self.slug})"
|
||||||
|
|
||||||
|
|||||||
@ -4,16 +4,16 @@ from typing import Any, Optional
|
|||||||
|
|
||||||
from django.core.cache import cache
|
from django.core.cache import cache
|
||||||
from django.http import HttpRequest
|
from django.http import HttpRequest
|
||||||
from prometheus_client import Gauge, Histogram
|
|
||||||
from sentry_sdk.hub import Hub
|
from sentry_sdk.hub import Hub
|
||||||
from sentry_sdk.tracing import Span
|
from sentry_sdk.tracing import Span
|
||||||
from structlog.stdlib import BoundLogger, get_logger
|
from structlog.stdlib import BoundLogger, get_logger
|
||||||
|
|
||||||
from authentik.core.models import User
|
from authentik.core.models import User
|
||||||
from authentik.events.models import cleanse_dict
|
from authentik.events.models import cleanse_dict
|
||||||
|
from authentik.flows.apps import HIST_FLOWS_PLAN_TIME
|
||||||
from authentik.flows.exceptions import EmptyFlowException, FlowNonApplicableException
|
from authentik.flows.exceptions import EmptyFlowException, FlowNonApplicableException
|
||||||
from authentik.flows.markers import ReevaluateMarker, StageMarker
|
from authentik.flows.markers import ReevaluateMarker, StageMarker
|
||||||
from authentik.flows.models import Flow, FlowDesignation, FlowStageBinding, Stage
|
from authentik.flows.models import Flow, FlowDesignation, FlowStageBinding, Stage, in_memory_stage
|
||||||
from authentik.lib.config import CONFIG
|
from authentik.lib.config import CONFIG
|
||||||
from authentik.policies.engine import PolicyEngine
|
from authentik.policies.engine import PolicyEngine
|
||||||
|
|
||||||
@ -26,15 +26,6 @@ PLAN_CONTEXT_SOURCE = "source"
|
|||||||
# Is set by the Flow Planner when a FlowToken was used, and the currently active flow plan
|
# Is set by the Flow Planner when a FlowToken was used, and the currently active flow plan
|
||||||
# was restored.
|
# was restored.
|
||||||
PLAN_CONTEXT_IS_RESTORED = "is_restored"
|
PLAN_CONTEXT_IS_RESTORED = "is_restored"
|
||||||
GAUGE_FLOWS_CACHED = Gauge(
|
|
||||||
"authentik_flows_cached",
|
|
||||||
"Cached flows",
|
|
||||||
)
|
|
||||||
HIST_FLOWS_PLAN_TIME = Histogram(
|
|
||||||
"authentik_flows_plan_time",
|
|
||||||
"Duration to build a plan for a flow",
|
|
||||||
["flow_slug"],
|
|
||||||
)
|
|
||||||
CACHE_TIMEOUT = int(CONFIG.y("redis.cache_timeout_flows"))
|
CACHE_TIMEOUT = int(CONFIG.y("redis.cache_timeout_flows"))
|
||||||
|
|
||||||
|
|
||||||
@ -71,6 +62,12 @@ class FlowPlan:
|
|||||||
self.bindings.insert(1, FlowStageBinding(stage=stage, order=0))
|
self.bindings.insert(1, FlowStageBinding(stage=stage, order=0))
|
||||||
self.markers.insert(1, marker or StageMarker())
|
self.markers.insert(1, marker or StageMarker())
|
||||||
|
|
||||||
|
def redirect(self, destination: str):
|
||||||
|
"""Insert a redirect stage as next stage"""
|
||||||
|
from authentik.flows.stage import RedirectStage
|
||||||
|
|
||||||
|
self.insert_stage(in_memory_stage(RedirectStage, destination=destination))
|
||||||
|
|
||||||
def next(self, http_request: Optional[HttpRequest]) -> Optional[FlowStageBinding]:
|
def next(self, http_request: Optional[HttpRequest]) -> Optional[FlowStageBinding]:
|
||||||
"""Return next pending stage from the bottom of the list"""
|
"""Return next pending stage from the bottom of the list"""
|
||||||
if not self.has_stages:
|
if not self.has_stages:
|
||||||
@ -117,7 +114,7 @@ class FlowPlanner:
|
|||||||
self.use_cache = True
|
self.use_cache = True
|
||||||
self.allow_empty_flows = False
|
self.allow_empty_flows = False
|
||||||
self.flow = flow
|
self.flow = flow
|
||||||
self._logger = get_logger().bind(flow=flow)
|
self._logger = get_logger().bind(flow_slug=flow.slug)
|
||||||
|
|
||||||
def plan(
|
def plan(
|
||||||
self, request: HttpRequest, default_context: Optional[dict[str, Any]] = None
|
self, request: HttpRequest, default_context: Optional[dict[str, Any]] = None
|
||||||
@ -146,11 +143,11 @@ class FlowPlanner:
|
|||||||
engine = PolicyEngine(self.flow, user, request)
|
engine = PolicyEngine(self.flow, user, request)
|
||||||
if default_context:
|
if default_context:
|
||||||
span.set_data("default_context", cleanse_dict(default_context))
|
span.set_data("default_context", cleanse_dict(default_context))
|
||||||
engine.request.context = default_context
|
engine.request.context.update(default_context)
|
||||||
engine.build()
|
engine.build()
|
||||||
result = engine.result
|
result = engine.result
|
||||||
if not result.passing:
|
if not result.passing:
|
||||||
exc = FlowNonApplicableException(",".join(result.messages))
|
exc = FlowNonApplicableException()
|
||||||
exc.policy_result = result
|
exc.policy_result = result
|
||||||
raise exc
|
raise exc
|
||||||
# User is passing so far, check if we have a cached plan
|
# User is passing so far, check if we have a cached plan
|
||||||
@ -207,7 +204,8 @@ class FlowPlanner:
|
|||||||
stage=binding.stage,
|
stage=binding.stage,
|
||||||
)
|
)
|
||||||
engine = PolicyEngine(binding, user, request)
|
engine = PolicyEngine(binding, user, request)
|
||||||
engine.request.context = plan.context
|
engine.request.context["flow_plan"] = plan
|
||||||
|
engine.request.context.update(plan.context)
|
||||||
engine.build()
|
engine.build()
|
||||||
if engine.passing:
|
if engine.passing:
|
||||||
self._logger.debug(
|
self._logger.debug(
|
||||||
|
|||||||
@ -4,7 +4,7 @@ from django.db.models.signals import post_save, pre_delete
|
|||||||
from django.dispatch import receiver
|
from django.dispatch import receiver
|
||||||
from structlog.stdlib import get_logger
|
from structlog.stdlib import get_logger
|
||||||
|
|
||||||
from authentik.flows.planner import GAUGE_FLOWS_CACHED
|
from authentik.flows.apps import GAUGE_FLOWS_CACHED
|
||||||
from authentik.root.monitoring import monitoring_set
|
from authentik.root.monitoring import monitoring_set
|
||||||
|
|
||||||
LOGGER = get_logger()
|
LOGGER = get_logger()
|
||||||
|
|||||||
@ -9,7 +9,7 @@ from django.urls import reverse
|
|||||||
from django.views.generic.base import View
|
from django.views.generic.base import View
|
||||||
from rest_framework.request import Request
|
from rest_framework.request import Request
|
||||||
from sentry_sdk.hub import Hub
|
from sentry_sdk.hub import Hub
|
||||||
from structlog.stdlib import get_logger
|
from structlog.stdlib import BoundLogger, get_logger
|
||||||
|
|
||||||
from authentik.core.models import DEFAULT_AVATAR, User
|
from authentik.core.models import DEFAULT_AVATAR, User
|
||||||
from authentik.flows.challenge import (
|
from authentik.flows.challenge import (
|
||||||
@ -19,27 +19,35 @@ from authentik.flows.challenge import (
|
|||||||
ChallengeTypes,
|
ChallengeTypes,
|
||||||
ContextualFlowInfo,
|
ContextualFlowInfo,
|
||||||
HttpChallengeResponse,
|
HttpChallengeResponse,
|
||||||
|
RedirectChallenge,
|
||||||
WithUserInfoChallenge,
|
WithUserInfoChallenge,
|
||||||
)
|
)
|
||||||
from authentik.flows.models import InvalidResponseAction
|
from authentik.flows.models import InvalidResponseAction
|
||||||
from authentik.flows.planner import PLAN_CONTEXT_APPLICATION, PLAN_CONTEXT_PENDING_USER
|
from authentik.flows.planner import PLAN_CONTEXT_APPLICATION, PLAN_CONTEXT_PENDING_USER
|
||||||
|
from authentik.lib.utils.reflection import class_to_path
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from authentik.flows.views.executor import FlowExecutorView
|
from authentik.flows.views.executor import FlowExecutorView
|
||||||
|
|
||||||
PLAN_CONTEXT_PENDING_USER_IDENTIFIER = "pending_user_identifier"
|
PLAN_CONTEXT_PENDING_USER_IDENTIFIER = "pending_user_identifier"
|
||||||
LOGGER = get_logger()
|
|
||||||
|
|
||||||
|
|
||||||
class StageView(View):
|
class StageView(View):
|
||||||
"""Abstract Stage, inherits TemplateView but can be combined with FormView"""
|
"""Abstract Stage"""
|
||||||
|
|
||||||
executor: "FlowExecutorView"
|
executor: "FlowExecutorView"
|
||||||
|
|
||||||
request: HttpRequest = None
|
request: HttpRequest = None
|
||||||
|
|
||||||
|
logger: BoundLogger
|
||||||
|
|
||||||
def __init__(self, executor: "FlowExecutorView", **kwargs):
|
def __init__(self, executor: "FlowExecutorView", **kwargs):
|
||||||
self.executor = executor
|
self.executor = executor
|
||||||
|
current_stage = getattr(self.executor, "current_stage", None)
|
||||||
|
self.logger = get_logger().bind(
|
||||||
|
stage=getattr(current_stage, "name", None),
|
||||||
|
stage_view=class_to_path(type(self)),
|
||||||
|
)
|
||||||
super().__init__(**kwargs)
|
super().__init__(**kwargs)
|
||||||
|
|
||||||
def get_pending_user(self, for_display=False) -> User:
|
def get_pending_user(self, for_display=False) -> User:
|
||||||
@ -60,6 +68,9 @@ class StageView(View):
|
|||||||
return self.executor.plan.context[PLAN_CONTEXT_PENDING_USER]
|
return self.executor.plan.context[PLAN_CONTEXT_PENDING_USER]
|
||||||
return self.request.user
|
return self.request.user
|
||||||
|
|
||||||
|
def cleanup(self):
|
||||||
|
"""Cleanup session"""
|
||||||
|
|
||||||
|
|
||||||
class ChallengeStageView(StageView):
|
class ChallengeStageView(StageView):
|
||||||
"""Stage view which response with a challenge"""
|
"""Stage view which response with a challenge"""
|
||||||
@ -74,12 +85,9 @@ class ChallengeStageView(StageView):
|
|||||||
"""Return a challenge for the frontend to solve"""
|
"""Return a challenge for the frontend to solve"""
|
||||||
challenge = self._get_challenge(*args, **kwargs)
|
challenge = self._get_challenge(*args, **kwargs)
|
||||||
if not challenge.is_valid():
|
if not challenge.is_valid():
|
||||||
LOGGER.warning(
|
self.logger.warning(
|
||||||
"f(ch): Invalid challenge",
|
"f(ch): Invalid challenge",
|
||||||
binding=self.executor.current_binding,
|
|
||||||
errors=challenge.errors,
|
errors=challenge.errors,
|
||||||
stage_view=self,
|
|
||||||
challenge=challenge,
|
|
||||||
)
|
)
|
||||||
return HttpChallengeResponse(challenge)
|
return HttpChallengeResponse(challenge)
|
||||||
|
|
||||||
@ -96,10 +104,8 @@ class ChallengeStageView(StageView):
|
|||||||
self.executor.current_binding.invalid_response_action
|
self.executor.current_binding.invalid_response_action
|
||||||
== InvalidResponseAction.RESTART_WITH_CONTEXT
|
== InvalidResponseAction.RESTART_WITH_CONTEXT
|
||||||
)
|
)
|
||||||
LOGGER.debug(
|
self.logger.debug(
|
||||||
"f(ch): Invalid response, restarting flow",
|
"f(ch): Invalid response, restarting flow",
|
||||||
binding=self.executor.current_binding,
|
|
||||||
stage_view=self,
|
|
||||||
keep_context=keep_context,
|
keep_context=keep_context,
|
||||||
)
|
)
|
||||||
return self.executor.restart_flow(keep_context)
|
return self.executor.restart_flow(keep_context)
|
||||||
@ -125,7 +131,7 @@ class ChallengeStageView(StageView):
|
|||||||
}
|
}
|
||||||
# pylint: disable=broad-except
|
# pylint: disable=broad-except
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
LOGGER.warning("failed to template title", exc=exc)
|
self.logger.warning("failed to template title", exc=exc)
|
||||||
return self.executor.flow.title
|
return self.executor.flow.title
|
||||||
|
|
||||||
def _get_challenge(self, *args, **kwargs) -> Challenge:
|
def _get_challenge(self, *args, **kwargs) -> Challenge:
|
||||||
@ -185,11 +191,9 @@ class ChallengeStageView(StageView):
|
|||||||
)
|
)
|
||||||
challenge_response.initial_data["response_errors"] = full_errors
|
challenge_response.initial_data["response_errors"] = full_errors
|
||||||
if not challenge_response.is_valid():
|
if not challenge_response.is_valid():
|
||||||
LOGGER.error(
|
self.logger.error(
|
||||||
"f(ch): invalid challenge response",
|
"f(ch): invalid challenge response",
|
||||||
binding=self.executor.current_binding,
|
|
||||||
errors=challenge_response.errors,
|
errors=challenge_response.errors,
|
||||||
stage_view=self,
|
|
||||||
)
|
)
|
||||||
return HttpChallengeResponse(challenge_response)
|
return HttpChallengeResponse(challenge_response)
|
||||||
|
|
||||||
@ -216,3 +220,21 @@ class AccessDeniedChallengeView(ChallengeStageView):
|
|||||||
# .get() method is called
|
# .get() method is called
|
||||||
def challenge_valid(self, response: ChallengeResponse) -> HttpResponse: # pragma: no cover
|
def challenge_valid(self, response: ChallengeResponse) -> HttpResponse: # pragma: no cover
|
||||||
return self.executor.cancel()
|
return self.executor.cancel()
|
||||||
|
|
||||||
|
|
||||||
|
class RedirectStage(ChallengeStageView):
|
||||||
|
"""Redirect to any URL"""
|
||||||
|
|
||||||
|
def get_challenge(self, *args, **kwargs) -> RedirectChallenge:
|
||||||
|
destination = getattr(
|
||||||
|
self.executor.current_stage, "destination", reverse("authentik_core:root-redirect")
|
||||||
|
)
|
||||||
|
return RedirectChallenge(
|
||||||
|
data={
|
||||||
|
"type": ChallengeTypes.REDIRECT.value,
|
||||||
|
"to": destination,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
def challenge_valid(self, response: ChallengeResponse) -> HttpResponse:
|
||||||
|
return HttpChallengeResponse(self.get_challenge())
|
||||||
|
|||||||
@ -6,14 +6,20 @@ from django.test.client import RequestFactory
|
|||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
|
||||||
from authentik.core.models import User
|
from authentik.core.models import User
|
||||||
from authentik.flows.exceptions import FlowNonApplicableException
|
from authentik.core.tests.utils import create_test_flow
|
||||||
from authentik.flows.markers import ReevaluateMarker, StageMarker
|
from authentik.flows.markers import ReevaluateMarker, StageMarker
|
||||||
from authentik.flows.models import Flow, FlowDesignation, FlowStageBinding, InvalidResponseAction
|
from authentik.flows.models import (
|
||||||
|
FlowDeniedAction,
|
||||||
|
FlowDesignation,
|
||||||
|
FlowStageBinding,
|
||||||
|
InvalidResponseAction,
|
||||||
|
)
|
||||||
from authentik.flows.planner import FlowPlan, FlowPlanner
|
from authentik.flows.planner import FlowPlan, FlowPlanner
|
||||||
from authentik.flows.stage import PLAN_CONTEXT_PENDING_USER_IDENTIFIER, StageView
|
from authentik.flows.stage import PLAN_CONTEXT_PENDING_USER_IDENTIFIER, StageView
|
||||||
from authentik.flows.tests import FlowTestCase
|
from authentik.flows.tests import FlowTestCase
|
||||||
from authentik.flows.views.executor import NEXT_ARG_NAME, SESSION_KEY_PLAN, FlowExecutorView
|
from authentik.flows.views.executor import NEXT_ARG_NAME, SESSION_KEY_PLAN, FlowExecutorView
|
||||||
from authentik.lib.config import CONFIG
|
from authentik.lib.config import CONFIG
|
||||||
|
from authentik.lib.generators import generate_id
|
||||||
from authentik.policies.dummy.models import DummyPolicy
|
from authentik.policies.dummy.models import DummyPolicy
|
||||||
from authentik.policies.models import PolicyBinding
|
from authentik.policies.models import PolicyBinding
|
||||||
from authentik.policies.reputation.models import ReputationPolicy
|
from authentik.policies.reputation.models import ReputationPolicy
|
||||||
@ -22,7 +28,7 @@ from authentik.stages.deny.models import DenyStage
|
|||||||
from authentik.stages.dummy.models import DummyStage
|
from authentik.stages.dummy.models import DummyStage
|
||||||
from authentik.stages.identification.models import IdentificationStage, UserFields
|
from authentik.stages.identification.models import IdentificationStage, UserFields
|
||||||
|
|
||||||
POLICY_RETURN_FALSE = PropertyMock(return_value=PolicyResult(False))
|
POLICY_RETURN_FALSE = PropertyMock(return_value=PolicyResult(False, "foo"))
|
||||||
POLICY_RETURN_TRUE = MagicMock(return_value=PolicyResult(True))
|
POLICY_RETURN_TRUE = MagicMock(return_value=PolicyResult(True))
|
||||||
|
|
||||||
|
|
||||||
@ -47,12 +53,10 @@ class TestFlowExecutor(FlowTestCase):
|
|||||||
)
|
)
|
||||||
def test_existing_plan_diff_flow(self):
|
def test_existing_plan_diff_flow(self):
|
||||||
"""Check that a plan for a different flow cancels the current plan"""
|
"""Check that a plan for a different flow cancels the current plan"""
|
||||||
flow = Flow.objects.create(
|
flow = create_test_flow(
|
||||||
name="test-existing-plan-diff",
|
FlowDesignation.AUTHENTICATION,
|
||||||
slug="test-existing-plan-diff",
|
|
||||||
designation=FlowDesignation.AUTHENTICATION,
|
|
||||||
)
|
)
|
||||||
stage = DummyStage.objects.create(name="dummy")
|
stage = DummyStage.objects.create(name=generate_id())
|
||||||
binding = FlowStageBinding(target=flow, stage=stage, order=0)
|
binding = FlowStageBinding(target=flow, stage=stage, order=0)
|
||||||
plan = FlowPlan(flow_pk=flow.pk.hex + "a", bindings=[binding], markers=[StageMarker()])
|
plan = FlowPlan(flow_pk=flow.pk.hex + "a", bindings=[binding], markers=[StageMarker()])
|
||||||
session = self.client.session
|
session = self.client.session
|
||||||
@ -77,10 +81,8 @@ class TestFlowExecutor(FlowTestCase):
|
|||||||
)
|
)
|
||||||
def test_invalid_non_applicable_flow(self):
|
def test_invalid_non_applicable_flow(self):
|
||||||
"""Tests that a non-applicable flow returns the correct error message"""
|
"""Tests that a non-applicable flow returns the correct error message"""
|
||||||
flow = Flow.objects.create(
|
flow = create_test_flow(
|
||||||
name="test-non-applicable",
|
FlowDesignation.AUTHENTICATION,
|
||||||
slug="test-non-applicable",
|
|
||||||
designation=FlowDesignation.AUTHENTICATION,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
CONFIG.update_from_dict({"domain": "testserver"})
|
CONFIG.update_from_dict({"domain": "testserver"})
|
||||||
@ -90,7 +92,7 @@ class TestFlowExecutor(FlowTestCase):
|
|||||||
self.assertStageResponse(
|
self.assertStageResponse(
|
||||||
response,
|
response,
|
||||||
flow=flow,
|
flow=flow,
|
||||||
error_message=FlowNonApplicableException.__doc__,
|
error_message="foo",
|
||||||
component="ak-stage-access-denied",
|
component="ak-stage-access-denied",
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -98,12 +100,15 @@ class TestFlowExecutor(FlowTestCase):
|
|||||||
"authentik.flows.views.executor.to_stage_response",
|
"authentik.flows.views.executor.to_stage_response",
|
||||||
TO_STAGE_RESPONSE_MOCK,
|
TO_STAGE_RESPONSE_MOCK,
|
||||||
)
|
)
|
||||||
def test_invalid_empty_flow(self):
|
@patch(
|
||||||
"""Tests that an empty flow returns the correct error message"""
|
"authentik.policies.engine.PolicyEngine.result",
|
||||||
flow = Flow.objects.create(
|
POLICY_RETURN_FALSE,
|
||||||
name="test-empty",
|
)
|
||||||
slug="test-empty",
|
def test_invalid_non_applicable_flow_continue(self):
|
||||||
designation=FlowDesignation.AUTHENTICATION,
|
"""Tests that a non-applicable flow that should redirect"""
|
||||||
|
flow = create_test_flow(
|
||||||
|
FlowDesignation.AUTHENTICATION,
|
||||||
|
denied_action=FlowDeniedAction.CONTINUE,
|
||||||
)
|
)
|
||||||
|
|
||||||
CONFIG.update_from_dict({"domain": "testserver"})
|
CONFIG.update_from_dict({"domain": "testserver"})
|
||||||
@ -119,10 +124,8 @@ class TestFlowExecutor(FlowTestCase):
|
|||||||
)
|
)
|
||||||
def test_invalid_flow_redirect(self):
|
def test_invalid_flow_redirect(self):
|
||||||
"""Tests that an invalid flow still redirects"""
|
"""Tests that an invalid flow still redirects"""
|
||||||
flow = Flow.objects.create(
|
flow = create_test_flow(
|
||||||
name="test-empty",
|
FlowDesignation.AUTHENTICATION,
|
||||||
slug="test-empty",
|
|
||||||
designation=FlowDesignation.AUTHENTICATION,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
CONFIG.update_from_dict({"domain": "testserver"})
|
CONFIG.update_from_dict({"domain": "testserver"})
|
||||||
@ -132,18 +135,33 @@ class TestFlowExecutor(FlowTestCase):
|
|||||||
self.assertEqual(response.status_code, 302)
|
self.assertEqual(response.status_code, 302)
|
||||||
self.assertEqual(response.url, reverse("authentik_core:root-redirect"))
|
self.assertEqual(response.url, reverse("authentik_core:root-redirect"))
|
||||||
|
|
||||||
|
@patch(
|
||||||
|
"authentik.flows.views.executor.to_stage_response",
|
||||||
|
TO_STAGE_RESPONSE_MOCK,
|
||||||
|
)
|
||||||
|
def test_invalid_empty_flow(self):
|
||||||
|
"""Tests that an empty flow returns the correct error message"""
|
||||||
|
flow = create_test_flow(
|
||||||
|
FlowDesignation.AUTHENTICATION,
|
||||||
|
)
|
||||||
|
|
||||||
|
CONFIG.update_from_dict({"domain": "testserver"})
|
||||||
|
response = self.client.get(
|
||||||
|
reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}),
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
self.assertEqual(response.url, reverse("authentik_core:root-redirect"))
|
||||||
|
|
||||||
def test_multi_stage_flow(self):
|
def test_multi_stage_flow(self):
|
||||||
"""Test a full flow with multiple stages"""
|
"""Test a full flow with multiple stages"""
|
||||||
flow = Flow.objects.create(
|
flow = create_test_flow(
|
||||||
name="test-full",
|
FlowDesignation.AUTHENTICATION,
|
||||||
slug="test-full",
|
|
||||||
designation=FlowDesignation.AUTHENTICATION,
|
|
||||||
)
|
)
|
||||||
FlowStageBinding.objects.create(
|
FlowStageBinding.objects.create(
|
||||||
target=flow, stage=DummyStage.objects.create(name="dummy1"), order=0
|
target=flow, stage=DummyStage.objects.create(name=generate_id()), order=0
|
||||||
)
|
)
|
||||||
FlowStageBinding.objects.create(
|
FlowStageBinding.objects.create(
|
||||||
target=flow, stage=DummyStage.objects.create(name="dummy2"), order=1
|
target=flow, stage=DummyStage.objects.create(name=generate_id()), order=1
|
||||||
)
|
)
|
||||||
|
|
||||||
exec_url = reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug})
|
exec_url = reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug})
|
||||||
@ -170,19 +188,19 @@ class TestFlowExecutor(FlowTestCase):
|
|||||||
)
|
)
|
||||||
def test_reevaluate_remove_last(self):
|
def test_reevaluate_remove_last(self):
|
||||||
"""Test planner with re-evaluate (last stage is removed)"""
|
"""Test planner with re-evaluate (last stage is removed)"""
|
||||||
flow = Flow.objects.create(
|
flow = create_test_flow(
|
||||||
name="test-default-context",
|
FlowDesignation.AUTHENTICATION,
|
||||||
slug="test-default-context",
|
)
|
||||||
designation=FlowDesignation.AUTHENTICATION,
|
false_policy = DummyPolicy.objects.create(
|
||||||
|
name=generate_id(), result=False, wait_min=1, wait_max=2
|
||||||
)
|
)
|
||||||
false_policy = DummyPolicy.objects.create(result=False, wait_min=1, wait_max=2)
|
|
||||||
|
|
||||||
binding = FlowStageBinding.objects.create(
|
binding = FlowStageBinding.objects.create(
|
||||||
target=flow, stage=DummyStage.objects.create(name="dummy1"), order=0
|
target=flow, stage=DummyStage.objects.create(name=generate_id()), order=0
|
||||||
)
|
)
|
||||||
binding2 = FlowStageBinding.objects.create(
|
binding2 = FlowStageBinding.objects.create(
|
||||||
target=flow,
|
target=flow,
|
||||||
stage=DummyStage.objects.create(name="dummy2"),
|
stage=DummyStage.objects.create(name=generate_id()),
|
||||||
order=1,
|
order=1,
|
||||||
re_evaluate_policies=True,
|
re_evaluate_policies=True,
|
||||||
)
|
)
|
||||||
@ -217,24 +235,24 @@ class TestFlowExecutor(FlowTestCase):
|
|||||||
|
|
||||||
def test_reevaluate_remove_middle(self):
|
def test_reevaluate_remove_middle(self):
|
||||||
"""Test planner with re-evaluate (middle stage is removed)"""
|
"""Test planner with re-evaluate (middle stage is removed)"""
|
||||||
flow = Flow.objects.create(
|
flow = create_test_flow(
|
||||||
name="test-default-context",
|
FlowDesignation.AUTHENTICATION,
|
||||||
slug="test-default-context",
|
)
|
||||||
designation=FlowDesignation.AUTHENTICATION,
|
false_policy = DummyPolicy.objects.create(
|
||||||
|
name=generate_id(), result=False, wait_min=1, wait_max=2
|
||||||
)
|
)
|
||||||
false_policy = DummyPolicy.objects.create(result=False, wait_min=1, wait_max=2)
|
|
||||||
|
|
||||||
binding = FlowStageBinding.objects.create(
|
binding = FlowStageBinding.objects.create(
|
||||||
target=flow, stage=DummyStage.objects.create(name="dummy1"), order=0
|
target=flow, stage=DummyStage.objects.create(name=generate_id()), order=0
|
||||||
)
|
)
|
||||||
binding2 = FlowStageBinding.objects.create(
|
binding2 = FlowStageBinding.objects.create(
|
||||||
target=flow,
|
target=flow,
|
||||||
stage=DummyStage.objects.create(name="dummy2"),
|
stage=DummyStage.objects.create(name=generate_id()),
|
||||||
order=1,
|
order=1,
|
||||||
re_evaluate_policies=True,
|
re_evaluate_policies=True,
|
||||||
)
|
)
|
||||||
binding3 = FlowStageBinding.objects.create(
|
binding3 = FlowStageBinding.objects.create(
|
||||||
target=flow, stage=DummyStage.objects.create(name="dummy3"), order=2
|
target=flow, stage=DummyStage.objects.create(name=generate_id()), order=2
|
||||||
)
|
)
|
||||||
|
|
||||||
PolicyBinding.objects.create(policy=false_policy, target=binding2, order=0)
|
PolicyBinding.objects.create(policy=false_policy, target=binding2, order=0)
|
||||||
@ -277,24 +295,24 @@ class TestFlowExecutor(FlowTestCase):
|
|||||||
|
|
||||||
def test_reevaluate_keep(self):
|
def test_reevaluate_keep(self):
|
||||||
"""Test planner with re-evaluate (everything is kept)"""
|
"""Test planner with re-evaluate (everything is kept)"""
|
||||||
flow = Flow.objects.create(
|
flow = create_test_flow(
|
||||||
name="test-default-context",
|
FlowDesignation.AUTHENTICATION,
|
||||||
slug="test-default-context",
|
)
|
||||||
designation=FlowDesignation.AUTHENTICATION,
|
true_policy = DummyPolicy.objects.create(
|
||||||
|
name=generate_id(), result=True, wait_min=1, wait_max=2
|
||||||
)
|
)
|
||||||
true_policy = DummyPolicy.objects.create(result=True, wait_min=1, wait_max=2)
|
|
||||||
|
|
||||||
binding = FlowStageBinding.objects.create(
|
binding = FlowStageBinding.objects.create(
|
||||||
target=flow, stage=DummyStage.objects.create(name="dummy1"), order=0
|
target=flow, stage=DummyStage.objects.create(name=generate_id()), order=0
|
||||||
)
|
)
|
||||||
binding2 = FlowStageBinding.objects.create(
|
binding2 = FlowStageBinding.objects.create(
|
||||||
target=flow,
|
target=flow,
|
||||||
stage=DummyStage.objects.create(name="dummy2"),
|
stage=DummyStage.objects.create(name=generate_id()),
|
||||||
order=1,
|
order=1,
|
||||||
re_evaluate_policies=True,
|
re_evaluate_policies=True,
|
||||||
)
|
)
|
||||||
binding3 = FlowStageBinding.objects.create(
|
binding3 = FlowStageBinding.objects.create(
|
||||||
target=flow, stage=DummyStage.objects.create(name="dummy3"), order=2
|
target=flow, stage=DummyStage.objects.create(name=generate_id()), order=2
|
||||||
)
|
)
|
||||||
|
|
||||||
PolicyBinding.objects.create(policy=true_policy, target=binding2, order=0)
|
PolicyBinding.objects.create(policy=true_policy, target=binding2, order=0)
|
||||||
@ -347,30 +365,30 @@ class TestFlowExecutor(FlowTestCase):
|
|||||||
|
|
||||||
def test_reevaluate_remove_consecutive(self):
|
def test_reevaluate_remove_consecutive(self):
|
||||||
"""Test planner with re-evaluate (consecutive stages are removed)"""
|
"""Test planner with re-evaluate (consecutive stages are removed)"""
|
||||||
flow = Flow.objects.create(
|
flow = create_test_flow(
|
||||||
name="test-default-context",
|
FlowDesignation.AUTHENTICATION,
|
||||||
slug="test-default-context",
|
)
|
||||||
designation=FlowDesignation.AUTHENTICATION,
|
false_policy = DummyPolicy.objects.create(
|
||||||
|
name=generate_id(), result=False, wait_min=1, wait_max=2
|
||||||
)
|
)
|
||||||
false_policy = DummyPolicy.objects.create(result=False, wait_min=1, wait_max=2)
|
|
||||||
|
|
||||||
binding = FlowStageBinding.objects.create(
|
binding = FlowStageBinding.objects.create(
|
||||||
target=flow, stage=DummyStage.objects.create(name="dummy1"), order=0
|
target=flow, stage=DummyStage.objects.create(name=generate_id()), order=0
|
||||||
)
|
)
|
||||||
binding2 = FlowStageBinding.objects.create(
|
binding2 = FlowStageBinding.objects.create(
|
||||||
target=flow,
|
target=flow,
|
||||||
stage=DummyStage.objects.create(name="dummy2"),
|
stage=DummyStage.objects.create(name=generate_id()),
|
||||||
order=1,
|
order=1,
|
||||||
re_evaluate_policies=True,
|
re_evaluate_policies=True,
|
||||||
)
|
)
|
||||||
binding3 = FlowStageBinding.objects.create(
|
binding3 = FlowStageBinding.objects.create(
|
||||||
target=flow,
|
target=flow,
|
||||||
stage=DummyStage.objects.create(name="dummy3"),
|
stage=DummyStage.objects.create(name=generate_id()),
|
||||||
order=2,
|
order=2,
|
||||||
re_evaluate_policies=True,
|
re_evaluate_policies=True,
|
||||||
)
|
)
|
||||||
binding4 = FlowStageBinding.objects.create(
|
binding4 = FlowStageBinding.objects.create(
|
||||||
target=flow, stage=DummyStage.objects.create(name="dummy4"), order=2
|
target=flow, stage=DummyStage.objects.create(name=generate_id()), order=2
|
||||||
)
|
)
|
||||||
|
|
||||||
PolicyBinding.objects.create(policy=false_policy, target=binding2, order=0)
|
PolicyBinding.objects.create(policy=false_policy, target=binding2, order=0)
|
||||||
@ -415,13 +433,11 @@ class TestFlowExecutor(FlowTestCase):
|
|||||||
|
|
||||||
def test_stageview_user_identifier(self):
|
def test_stageview_user_identifier(self):
|
||||||
"""Test PLAN_CONTEXT_PENDING_USER_IDENTIFIER"""
|
"""Test PLAN_CONTEXT_PENDING_USER_IDENTIFIER"""
|
||||||
flow = Flow.objects.create(
|
flow = create_test_flow(
|
||||||
name="test-default-context",
|
FlowDesignation.AUTHENTICATION,
|
||||||
slug="test-default-context",
|
|
||||||
designation=FlowDesignation.AUTHENTICATION,
|
|
||||||
)
|
)
|
||||||
FlowStageBinding.objects.create(
|
FlowStageBinding.objects.create(
|
||||||
target=flow, stage=DummyStage.objects.create(name="dummy"), order=0
|
target=flow, stage=DummyStage.objects.create(name=generate_id()), order=0
|
||||||
)
|
)
|
||||||
|
|
||||||
ident = "test-identifier"
|
ident = "test-identifier"
|
||||||
@ -443,10 +459,8 @@ class TestFlowExecutor(FlowTestCase):
|
|||||||
|
|
||||||
def test_invalid_restart(self):
|
def test_invalid_restart(self):
|
||||||
"""Test flow that restarts on invalid entry"""
|
"""Test flow that restarts on invalid entry"""
|
||||||
flow = Flow.objects.create(
|
flow = create_test_flow(
|
||||||
name="restart-on-invalid",
|
FlowDesignation.AUTHENTICATION,
|
||||||
slug="restart-on-invalid",
|
|
||||||
designation=FlowDesignation.AUTHENTICATION,
|
|
||||||
)
|
)
|
||||||
# Stage 0 is a deny stage that is added dynamically
|
# Stage 0 is a deny stage that is added dynamically
|
||||||
# when the reputation policy says so
|
# when the reputation policy says so
|
||||||
|
|||||||
@ -9,6 +9,7 @@ from rest_framework.test import APITestCase
|
|||||||
from authentik.core.tests.utils import create_test_admin_user
|
from authentik.core.tests.utils import create_test_admin_user
|
||||||
from authentik.flows.challenge import ChallengeTypes
|
from authentik.flows.challenge import ChallengeTypes
|
||||||
from authentik.flows.models import Flow, FlowDesignation, FlowStageBinding, InvalidResponseAction
|
from authentik.flows.models import Flow, FlowDesignation, FlowStageBinding, InvalidResponseAction
|
||||||
|
from authentik.lib.generators import generate_id
|
||||||
from authentik.stages.dummy.models import DummyStage
|
from authentik.stages.dummy.models import DummyStage
|
||||||
from authentik.stages.identification.models import IdentificationStage, UserFields
|
from authentik.stages.identification.models import IdentificationStage, UserFields
|
||||||
|
|
||||||
@ -24,8 +25,8 @@ class TestFlowInspector(APITestCase):
|
|||||||
def test(self):
|
def test(self):
|
||||||
"""test inspector"""
|
"""test inspector"""
|
||||||
flow = Flow.objects.create(
|
flow = Flow.objects.create(
|
||||||
name="test-full",
|
name=generate_id(),
|
||||||
slug="test-full",
|
slug=generate_id(),
|
||||||
designation=FlowDesignation.AUTHENTICATION,
|
designation=FlowDesignation.AUTHENTICATION,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@ -13,6 +13,26 @@ from authentik.policies.models import PolicyBinding
|
|||||||
from authentik.stages.prompt.models import FieldTypes, Prompt, PromptStage
|
from authentik.stages.prompt.models import FieldTypes, Prompt, PromptStage
|
||||||
from authentik.stages.user_login.models import UserLoginStage
|
from authentik.stages.user_login.models import UserLoginStage
|
||||||
|
|
||||||
|
STATIC_PROMPT_EXPORT = """{
|
||||||
|
"version": 1,
|
||||||
|
"entries": [
|
||||||
|
{
|
||||||
|
"identifiers": {
|
||||||
|
"pk": "cb954fd4-65a5-4ad9-b1ee-180ee9559cf4"
|
||||||
|
},
|
||||||
|
"model": "authentik_stages_prompt.prompt",
|
||||||
|
"attrs": {
|
||||||
|
"field_key": "username",
|
||||||
|
"label": "Username",
|
||||||
|
"type": "username",
|
||||||
|
"required": true,
|
||||||
|
"placeholder": "Username",
|
||||||
|
"order": 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}"""
|
||||||
|
|
||||||
|
|
||||||
class TestFlowTransfer(TransactionTestCase):
|
class TestFlowTransfer(TransactionTestCase):
|
||||||
"""Test flow transfer"""
|
"""Test flow transfer"""
|
||||||
@ -58,6 +78,22 @@ class TestFlowTransfer(TransactionTestCase):
|
|||||||
|
|
||||||
self.assertTrue(Flow.objects.filter(slug=flow_slug).exists())
|
self.assertTrue(Flow.objects.filter(slug=flow_slug).exists())
|
||||||
|
|
||||||
|
def test_export_validate_import_re_import(self):
|
||||||
|
"""Test export and import it twice"""
|
||||||
|
count_initial = Prompt.objects.filter(field_key="username").count()
|
||||||
|
|
||||||
|
importer = FlowImporter(STATIC_PROMPT_EXPORT)
|
||||||
|
self.assertTrue(importer.validate())
|
||||||
|
self.assertTrue(importer.apply())
|
||||||
|
|
||||||
|
count_before = Prompt.objects.filter(field_key="username").count()
|
||||||
|
self.assertEqual(count_initial + 1, count_before)
|
||||||
|
|
||||||
|
importer = FlowImporter(STATIC_PROMPT_EXPORT)
|
||||||
|
self.assertTrue(importer.apply())
|
||||||
|
|
||||||
|
self.assertEqual(Prompt.objects.filter(field_key="username").count(), count_before)
|
||||||
|
|
||||||
def test_export_validate_import_policies(self):
|
def test_export_validate_import_policies(self):
|
||||||
"""Test export and validate it"""
|
"""Test export and validate it"""
|
||||||
flow_slug = generate_id()
|
flow_slug = generate_id()
|
||||||
|
|||||||
@ -27,6 +27,7 @@ def get_attrs(obj: SerializerModel) -> dict[str, Any]:
|
|||||||
"promptstage_set",
|
"promptstage_set",
|
||||||
"policybindingmodel_ptr_id",
|
"policybindingmodel_ptr_id",
|
||||||
"export_url",
|
"export_url",
|
||||||
|
"meta_model_name",
|
||||||
)
|
)
|
||||||
for to_remove_name in to_remove:
|
for to_remove_name in to_remove:
|
||||||
if to_remove_name in data:
|
if to_remove_name in data:
|
||||||
|
|||||||
@ -28,6 +28,7 @@ ALLOWED_MODELS = (Flow, FlowStageBinding, Stage, Policy, PolicyBinding, Prompt)
|
|||||||
def transaction_rollback():
|
def transaction_rollback():
|
||||||
"""Enters an atomic transaction and always triggers a rollback at the end of the block."""
|
"""Enters an atomic transaction and always triggers a rollback at the end of the block."""
|
||||||
atomic = transaction.atomic()
|
atomic = transaction.atomic()
|
||||||
|
# pylint: disable=unnecessary-dunder-call
|
||||||
atomic.__enter__()
|
atomic.__enter__()
|
||||||
yield
|
yield
|
||||||
atomic.__exit__(IntegrityError, None, None)
|
atomic.__exit__(IntegrityError, None, None)
|
||||||
@ -115,6 +116,11 @@ class FlowImporter:
|
|||||||
serializer_kwargs["instance"] = model_instance
|
serializer_kwargs["instance"] = model_instance
|
||||||
else:
|
else:
|
||||||
self.logger.debug("initialise new instance", model=model, **updated_identifiers)
|
self.logger.debug("initialise new instance", model=model, **updated_identifiers)
|
||||||
|
model_instance = model()
|
||||||
|
# pk needs to be set on the model instance otherwise a new one will be generated
|
||||||
|
if "pk" in updated_identifiers:
|
||||||
|
model_instance.pk = updated_identifiers["pk"]
|
||||||
|
serializer_kwargs["instance"] = model_instance
|
||||||
full_data = self.__update_pks_for_attrs(entry.attrs)
|
full_data = self.__update_pks_for_attrs(entry.attrs)
|
||||||
full_data.update(updated_identifiers)
|
full_data.update(updated_identifiers)
|
||||||
serializer_kwargs["data"] = full_data
|
serializer_kwargs["data"] = full_data
|
||||||
@ -167,7 +173,7 @@ class FlowImporter:
|
|||||||
def validate(self) -> bool:
|
def validate(self) -> bool:
|
||||||
"""Validate loaded flow export, ensure all models are allowed
|
"""Validate loaded flow export, ensure all models are allowed
|
||||||
and serializers have no errors"""
|
and serializers have no errors"""
|
||||||
self.logger.debug("Starting flow import validaton")
|
self.logger.debug("Starting flow import validation")
|
||||||
if self.__import.version != 1:
|
if self.__import.version != 1:
|
||||||
self.logger.warning("Invalid bundle version")
|
self.logger.warning("Invalid bundle version")
|
||||||
return False
|
return False
|
||||||
|
|||||||
@ -10,6 +10,7 @@ from django.http import Http404, HttpRequest, HttpResponse, HttpResponseRedirect
|
|||||||
from django.http.request import QueryDict
|
from django.http.request import QueryDict
|
||||||
from django.shortcuts import get_object_or_404, redirect
|
from django.shortcuts import get_object_or_404, redirect
|
||||||
from django.template.response import TemplateResponse
|
from django.template.response import TemplateResponse
|
||||||
|
from django.urls import reverse
|
||||||
from django.utils.decorators import method_decorator
|
from django.utils.decorators import method_decorator
|
||||||
from django.views.decorators.clickjacking import xframe_options_sameorigin
|
from django.views.decorators.clickjacking import xframe_options_sameorigin
|
||||||
from django.views.generic import View
|
from django.views.generic import View
|
||||||
@ -37,6 +38,7 @@ from authentik.flows.exceptions import EmptyFlowException, FlowNonApplicableExce
|
|||||||
from authentik.flows.models import (
|
from authentik.flows.models import (
|
||||||
ConfigurableStage,
|
ConfigurableStage,
|
||||||
Flow,
|
Flow,
|
||||||
|
FlowDeniedAction,
|
||||||
FlowDesignation,
|
FlowDesignation,
|
||||||
FlowStageBinding,
|
FlowStageBinding,
|
||||||
FlowToken,
|
FlowToken,
|
||||||
@ -49,21 +51,22 @@ from authentik.flows.planner import (
|
|||||||
FlowPlan,
|
FlowPlan,
|
||||||
FlowPlanner,
|
FlowPlanner,
|
||||||
)
|
)
|
||||||
from authentik.flows.stage import AccessDeniedChallengeView
|
from authentik.flows.stage import AccessDeniedChallengeView, StageView
|
||||||
from authentik.lib.sentry import SentryIgnoredException
|
from authentik.lib.sentry import SentryIgnoredException
|
||||||
from authentik.lib.utils.errors import exception_to_string
|
from authentik.lib.utils.errors import exception_to_string
|
||||||
from authentik.lib.utils.reflection import all_subclasses, class_to_path
|
from authentik.lib.utils.reflection import all_subclasses, class_to_path
|
||||||
from authentik.lib.utils.urls import is_url_absolute, redirect_with_qs
|
from authentik.lib.utils.urls import is_url_absolute, redirect_with_qs
|
||||||
|
from authentik.policies.engine import PolicyEngine
|
||||||
from authentik.tenants.models import Tenant
|
from authentik.tenants.models import Tenant
|
||||||
|
|
||||||
LOGGER = get_logger()
|
LOGGER = get_logger()
|
||||||
# Argument used to redirect user after login
|
# Argument used to redirect user after login
|
||||||
NEXT_ARG_NAME = "next"
|
NEXT_ARG_NAME = "next"
|
||||||
SESSION_KEY_PLAN = "authentik_flows_plan"
|
SESSION_KEY_PLAN = "authentik/flows/plan"
|
||||||
SESSION_KEY_APPLICATION_PRE = "authentik_flows_application_pre"
|
SESSION_KEY_APPLICATION_PRE = "authentik/flows/application_pre"
|
||||||
SESSION_KEY_GET = "authentik_flows_get"
|
SESSION_KEY_GET = "authentik/flows/get"
|
||||||
SESSION_KEY_POST = "authentik_flows_post"
|
SESSION_KEY_POST = "authentik/flows/post"
|
||||||
SESSION_KEY_HISTORY = "authentik_flows_history"
|
SESSION_KEY_HISTORY = "authentik/flows/history"
|
||||||
QS_KEY_TOKEN = "flow_token" # nosec
|
QS_KEY_TOKEN = "flow_token" # nosec
|
||||||
|
|
||||||
|
|
||||||
@ -129,21 +132,27 @@ class FlowExecutorView(APIView):
|
|||||||
self._logger = get_logger().bind(flow_slug=flow_slug)
|
self._logger = get_logger().bind(flow_slug=flow_slug)
|
||||||
set_tag("authentik.flow", self.flow.slug)
|
set_tag("authentik.flow", self.flow.slug)
|
||||||
|
|
||||||
def handle_invalid_flow(self, exc: BaseException) -> HttpResponse:
|
def handle_invalid_flow(self, exc: FlowNonApplicableException) -> HttpResponse:
|
||||||
"""When a flow is non-applicable check if user is on the correct domain"""
|
"""When a flow is non-applicable check if user is on the correct domain"""
|
||||||
if NEXT_ARG_NAME in self.request.GET:
|
if self.flow.denied_action in [
|
||||||
if not is_url_absolute(self.request.GET.get(NEXT_ARG_NAME)):
|
FlowDeniedAction.CONTINUE,
|
||||||
|
FlowDeniedAction.MESSAGE_CONTINUE,
|
||||||
|
]:
|
||||||
|
next_url = self.request.GET.get(NEXT_ARG_NAME)
|
||||||
|
if next_url and not is_url_absolute(next_url):
|
||||||
self._logger.debug("f(exec): Redirecting to next on fail")
|
self._logger.debug("f(exec): Redirecting to next on fail")
|
||||||
return redirect(self.request.GET.get(NEXT_ARG_NAME))
|
return to_stage_response(self.request, redirect(next_url))
|
||||||
message = exc.__doc__ if exc.__doc__ else str(exc)
|
if self.flow.denied_action == FlowDeniedAction.CONTINUE:
|
||||||
return self.stage_invalid(error_message=message)
|
return to_stage_response(
|
||||||
|
self.request, redirect(reverse("authentik_core:root-redirect"))
|
||||||
|
)
|
||||||
|
return to_stage_response(self.request, self.stage_invalid(error_message=exc.messages))
|
||||||
|
|
||||||
def _check_flow_token(self, get_params: QueryDict):
|
def _check_flow_token(self, key: str) -> Optional[FlowPlan]:
|
||||||
"""Check if the user is using a flow token to restore a plan"""
|
"""Check if the user is using a flow token to restore a plan"""
|
||||||
tokens = FlowToken.filter_not_expired(key=get_params[QS_KEY_TOKEN])
|
token: Optional[FlowToken] = FlowToken.filter_not_expired(key=key).first()
|
||||||
if not tokens.exists():
|
if not token:
|
||||||
return False
|
return None
|
||||||
token: FlowToken = tokens.first()
|
|
||||||
try:
|
try:
|
||||||
plan = token.plan
|
plan = token.plan
|
||||||
except (AttributeError, EOFError, ImportError, IndexError) as exc:
|
except (AttributeError, EOFError, ImportError, IndexError) as exc:
|
||||||
@ -164,7 +173,7 @@ class FlowExecutorView(APIView):
|
|||||||
span.set_data("authentik Flow", self.flow.slug)
|
span.set_data("authentik Flow", self.flow.slug)
|
||||||
get_params = QueryDict(request.GET.get("query", ""))
|
get_params = QueryDict(request.GET.get("query", ""))
|
||||||
if QS_KEY_TOKEN in get_params:
|
if QS_KEY_TOKEN in get_params:
|
||||||
plan = self._check_flow_token(get_params)
|
plan = self._check_flow_token(get_params[QS_KEY_TOKEN])
|
||||||
if plan:
|
if plan:
|
||||||
self.request.session[SESSION_KEY_PLAN] = plan
|
self.request.session[SESSION_KEY_PLAN] = plan
|
||||||
# Early check if there's an active Plan for the current session
|
# Early check if there's an active Plan for the current session
|
||||||
@ -188,7 +197,7 @@ class FlowExecutorView(APIView):
|
|||||||
self.plan = self._initiate_plan()
|
self.plan = self._initiate_plan()
|
||||||
except FlowNonApplicableException as exc:
|
except FlowNonApplicableException as exc:
|
||||||
self._logger.warning("f(exec): Flow not applicable to current user", exc=exc)
|
self._logger.warning("f(exec): Flow not applicable to current user", exc=exc)
|
||||||
return to_stage_response(self.request, self.handle_invalid_flow(exc))
|
return self.handle_invalid_flow(exc)
|
||||||
except EmptyFlowException as exc:
|
except EmptyFlowException as exc:
|
||||||
self._logger.warning("f(exec): Flow is empty", exc=exc)
|
self._logger.warning("f(exec): Flow is empty", exc=exc)
|
||||||
# To match behaviour with loading an empty flow plan from cache,
|
# To match behaviour with loading an empty flow plan from cache,
|
||||||
@ -380,6 +389,8 @@ class FlowExecutorView(APIView):
|
|||||||
"f(exec): Stage ok",
|
"f(exec): Stage ok",
|
||||||
stage_class=class_to_path(self.current_stage_view.__class__),
|
stage_class=class_to_path(self.current_stage_view.__class__),
|
||||||
)
|
)
|
||||||
|
if isinstance(self.current_stage_view, StageView):
|
||||||
|
self.current_stage_view.cleanup()
|
||||||
self.request.session.get(SESSION_KEY_HISTORY, []).append(deepcopy(self.plan))
|
self.request.session.get(SESSION_KEY_HISTORY, []).append(deepcopy(self.plan))
|
||||||
self.plan.pop()
|
self.plan.pop()
|
||||||
self.request.session[SESSION_KEY_PLAN] = self.plan
|
self.request.session[SESSION_KEY_PLAN] = self.plan
|
||||||
@ -416,11 +427,14 @@ class FlowExecutorView(APIView):
|
|||||||
SESSION_KEY_APPLICATION_PRE,
|
SESSION_KEY_APPLICATION_PRE,
|
||||||
SESSION_KEY_PLAN,
|
SESSION_KEY_PLAN,
|
||||||
SESSION_KEY_GET,
|
SESSION_KEY_GET,
|
||||||
|
# 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
|
# We don't delete the history on purpose, as a user might
|
||||||
# still be inspecting it.
|
# still be inspecting it.
|
||||||
# It's only deleted on a fresh executions
|
# It's only deleted on a fresh executions
|
||||||
# SESSION_KEY_HISTORY,
|
# SESSION_KEY_HISTORY,
|
||||||
]
|
]
|
||||||
|
self._logger.debug("f(exec): cleaning up")
|
||||||
for key in keys_to_delete:
|
for key in keys_to_delete:
|
||||||
if key in self.request.session:
|
if key in self.request.session:
|
||||||
del self.request.session[key]
|
del self.request.session[key]
|
||||||
@ -466,6 +480,20 @@ class ToDefaultFlow(View):
|
|||||||
|
|
||||||
designation: Optional[FlowDesignation] = None
|
designation: Optional[FlowDesignation] = None
|
||||||
|
|
||||||
|
def flow_by_policy(self, request: HttpRequest, **flow_filter) -> Optional[Flow]:
|
||||||
|
"""Get a Flow by `**flow_filter` and check if the request from `request` can access it."""
|
||||||
|
flows = Flow.objects.filter(**flow_filter).order_by("slug")
|
||||||
|
for flow in flows:
|
||||||
|
engine = PolicyEngine(flow, request.user, request)
|
||||||
|
engine.build()
|
||||||
|
result = engine.result
|
||||||
|
if result.passing:
|
||||||
|
LOGGER.debug("flow_by_policy: flow passing", flow=flow)
|
||||||
|
return flow
|
||||||
|
LOGGER.warning("flow_by_policy: flow not passing", flow=flow, messages=result.messages)
|
||||||
|
LOGGER.debug("flow_by_policy: no flow found", filters=flow_filter)
|
||||||
|
return None
|
||||||
|
|
||||||
def dispatch(self, request: HttpRequest) -> HttpResponse:
|
def dispatch(self, request: HttpRequest) -> HttpResponse:
|
||||||
tenant: Tenant = request.tenant
|
tenant: Tenant = request.tenant
|
||||||
flow = None
|
flow = None
|
||||||
@ -476,7 +504,7 @@ class ToDefaultFlow(View):
|
|||||||
flow = tenant.flow_invalidation
|
flow = tenant.flow_invalidation
|
||||||
# If no flow was set, get the first based on slug and policy
|
# If no flow was set, get the first based on slug and policy
|
||||||
if not flow:
|
if not flow:
|
||||||
flow = Flow.with_policy(request, designation=self.designation)
|
flow = self.flow_by_policy(request, designation=self.designation)
|
||||||
# If we still don't have a flow, 404
|
# If we still don't have a flow, 404
|
||||||
if not flow:
|
if not flow:
|
||||||
raise Http404
|
raise Http404
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
# update website/docs/installation/configuration.md
|
||||||
# This is the default configuration file
|
# This is the default configuration file
|
||||||
postgresql:
|
postgresql:
|
||||||
host: localhost
|
host: localhost
|
||||||
@ -57,6 +58,10 @@ outposts:
|
|||||||
container_image_base: ghcr.io/goauthentik/%(type)s:%(version)s
|
container_image_base: ghcr.io/goauthentik/%(type)s:%(version)s
|
||||||
discover: true
|
discover: true
|
||||||
|
|
||||||
|
ldap:
|
||||||
|
tls:
|
||||||
|
ciphers: null
|
||||||
|
|
||||||
cookie_domain: null
|
cookie_domain: null
|
||||||
disable_update_check: false
|
disable_update_check: false
|
||||||
disable_startup_analytics: false
|
disable_startup_analytics: false
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
"""authentik sentry integration"""
|
"""authentik sentry integration"""
|
||||||
from typing import Optional
|
from typing import Any, Optional
|
||||||
|
|
||||||
from aioredis.errors import ConnectionClosedError, ReplyError
|
from aioredis.errors import ConnectionClosedError, ReplyError
|
||||||
from billiard.exceptions import SoftTimeLimitExceeded, WorkerLostError
|
from billiard.exceptions import SoftTimeLimitExceeded, WorkerLostError
|
||||||
@ -17,7 +17,7 @@ from ldap3.core.exceptions import LDAPException
|
|||||||
from redis.exceptions import ConnectionError as RedisConnectionError
|
from redis.exceptions import ConnectionError as RedisConnectionError
|
||||||
from redis.exceptions import RedisError, ResponseError
|
from redis.exceptions import RedisError, ResponseError
|
||||||
from rest_framework.exceptions import APIException
|
from rest_framework.exceptions import APIException
|
||||||
from sentry_sdk import Hub
|
from sentry_sdk import HttpTransport, Hub
|
||||||
from sentry_sdk import init as sentry_sdk_init
|
from sentry_sdk import init as sentry_sdk_init
|
||||||
from sentry_sdk.api import set_tag
|
from sentry_sdk.api import set_tag
|
||||||
from sentry_sdk.integrations.celery import CeleryIntegration
|
from sentry_sdk.integrations.celery import CeleryIntegration
|
||||||
@ -30,6 +30,7 @@ from websockets.exceptions import WebSocketException
|
|||||||
|
|
||||||
from authentik import __version__, get_build_hash
|
from authentik import __version__, get_build_hash
|
||||||
from authentik.lib.config import CONFIG
|
from authentik.lib.config import CONFIG
|
||||||
|
from authentik.lib.utils.http import authentik_user_agent
|
||||||
from authentik.lib.utils.reflection import class_to_path, get_env
|
from authentik.lib.utils.reflection import class_to_path, get_env
|
||||||
|
|
||||||
LOGGER = get_logger()
|
LOGGER = get_logger()
|
||||||
@ -52,11 +53,18 @@ class SentryIgnoredException(Exception):
|
|||||||
"""Base Class for all errors that are suppressed, and not sent to sentry."""
|
"""Base Class for all errors that are suppressed, and not sent to sentry."""
|
||||||
|
|
||||||
|
|
||||||
|
class SentryTransport(HttpTransport):
|
||||||
|
"""Custom sentry transport with custom user-agent"""
|
||||||
|
|
||||||
|
def __init__(self, options: dict[str, Any]) -> None:
|
||||||
|
super().__init__(options)
|
||||||
|
self._auth = self.parsed_dsn.to_auth(authentik_user_agent())
|
||||||
|
|
||||||
|
|
||||||
def sentry_init(**sentry_init_kwargs):
|
def sentry_init(**sentry_init_kwargs):
|
||||||
"""Configure sentry SDK"""
|
"""Configure sentry SDK"""
|
||||||
sentry_env = CONFIG.y("error_reporting.environment", "customer")
|
sentry_env = CONFIG.y("error_reporting.environment", "customer")
|
||||||
kwargs = {
|
kwargs = {
|
||||||
"traces_sample_rate": float(CONFIG.y("error_reporting.sample_rate", 0.5)),
|
|
||||||
"environment": sentry_env,
|
"environment": sentry_env,
|
||||||
"send_default_pii": CONFIG.y_bool("error_reporting.send_pii", False),
|
"send_default_pii": CONFIG.y_bool("error_reporting.send_pii", False),
|
||||||
}
|
}
|
||||||
@ -71,7 +79,9 @@ def sentry_init(**sentry_init_kwargs):
|
|||||||
ThreadingIntegration(propagate_hub=True),
|
ThreadingIntegration(propagate_hub=True),
|
||||||
],
|
],
|
||||||
before_send=before_send,
|
before_send=before_send,
|
||||||
|
traces_sampler=traces_sampler,
|
||||||
release=f"authentik@{__version__}",
|
release=f"authentik@{__version__}",
|
||||||
|
transport=SentryTransport,
|
||||||
**kwargs,
|
**kwargs,
|
||||||
)
|
)
|
||||||
set_tag("authentik.build_hash", get_build_hash("tagged"))
|
set_tag("authentik.build_hash", get_build_hash("tagged"))
|
||||||
@ -83,6 +93,15 @@ def sentry_init(**sentry_init_kwargs):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def traces_sampler(sampling_context: dict) -> float:
|
||||||
|
"""Custom sampler to ignore certain routes"""
|
||||||
|
path = sampling_context.get("asgi_scope", {}).get("path", "")
|
||||||
|
# Ignore all healthcheck routes
|
||||||
|
if path.startswith("/-/health") or path.startswith("/-/metrics"):
|
||||||
|
return 0
|
||||||
|
return float(CONFIG.y("error_reporting.sample_rate", 0.5))
|
||||||
|
|
||||||
|
|
||||||
def before_send(event: dict, hint: dict) -> Optional[dict]:
|
def before_send(event: dict, hint: dict) -> Optional[dict]:
|
||||||
"""Check if error is database error, and ignore if so"""
|
"""Check if error is database error, and ignore if so"""
|
||||||
# pylint: disable=no-name-in-module
|
# pylint: disable=no-name-in-module
|
||||||
|
|||||||
@ -1,10 +1,18 @@
|
|||||||
"""error utils"""
|
"""error utils"""
|
||||||
from traceback import format_tb
|
from traceback import extract_tb
|
||||||
|
|
||||||
TRACEBACK_HEADER = "Traceback (most recent call last):\n"
|
from authentik.lib.utils.reflection import class_to_path
|
||||||
|
|
||||||
|
TRACEBACK_HEADER = "Traceback (most recent call last):"
|
||||||
|
|
||||||
|
|
||||||
def exception_to_string(exc: Exception) -> str:
|
def exception_to_string(exc: Exception) -> str:
|
||||||
"""Convert exception to string stackrace"""
|
"""Convert exception to string stackrace"""
|
||||||
# Either use passed original exception or whatever we have
|
# Either use passed original exception or whatever we have
|
||||||
return TRACEBACK_HEADER + "".join(format_tb(exc.__traceback__)) + str(exc)
|
return "\n".join(
|
||||||
|
[
|
||||||
|
TRACEBACK_HEADER,
|
||||||
|
*[x.rstrip() for x in extract_tb(exc.__traceback__).format()],
|
||||||
|
f"{class_to_path(exc.__class__)}: {str(exc)}",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|||||||
12
authentik/lib/xml.py
Normal file
12
authentik/lib/xml.py
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
"""XML Utilities"""
|
||||||
|
from lxml.etree import XMLParser, fromstring # nosec
|
||||||
|
|
||||||
|
|
||||||
|
def get_lxml_parser():
|
||||||
|
"""Get XML parser"""
|
||||||
|
return XMLParser(resolve_entities=False)
|
||||||
|
|
||||||
|
|
||||||
|
def lxml_from_string(text: str):
|
||||||
|
"""Wrapper around fromstring"""
|
||||||
|
return fromstring(text, parser=get_lxml_parser())
|
||||||
@ -12,5 +12,4 @@ class AuthentikManagedConfig(AppConfig):
|
|||||||
def ready(self) -> None:
|
def ready(self) -> None:
|
||||||
from authentik.managed.tasks import managed_reconcile
|
from authentik.managed.tasks import managed_reconcile
|
||||||
|
|
||||||
# pyright: reportGeneralTypeIssues=false
|
managed_reconcile.delay()
|
||||||
managed_reconcile.delay() # pylint: disable=no-value-for-parameter
|
|
||||||
|
|||||||
@ -11,7 +11,11 @@ from authentik.events.monitored_tasks import (
|
|||||||
from authentik.managed.manager import ObjectManager
|
from authentik.managed.manager import ObjectManager
|
||||||
|
|
||||||
|
|
||||||
@CELERY_APP.task(bind=True, base=MonitoredTask)
|
@CELERY_APP.task(
|
||||||
|
bind=True,
|
||||||
|
base=MonitoredTask,
|
||||||
|
retry_backoff=True,
|
||||||
|
)
|
||||||
@prefill_task
|
@prefill_task
|
||||||
def managed_reconcile(self: MonitoredTask):
|
def managed_reconcile(self: MonitoredTask):
|
||||||
"""Run ObjectManager to ensure objects are up-to-date"""
|
"""Run ObjectManager to ensure objects are up-to-date"""
|
||||||
@ -22,3 +26,4 @@ def managed_reconcile(self: MonitoredTask):
|
|||||||
)
|
)
|
||||||
except DatabaseError as exc: # pragma: no cover
|
except DatabaseError as exc: # pragma: no cover
|
||||||
self.set_status(TaskResult(TaskResultStatus.WARNING, [str(exc)]))
|
self.set_status(TaskResult(TaskResultStatus.WARNING, [str(exc)]))
|
||||||
|
self.retry()
|
||||||
|
|||||||
@ -118,6 +118,7 @@ class DockerServiceConnectionViewSet(UsedByMixin, ModelViewSet):
|
|||||||
serializer_class = DockerServiceConnectionSerializer
|
serializer_class = DockerServiceConnectionSerializer
|
||||||
filterset_fields = ["name", "local", "url", "tls_verification", "tls_authentication"]
|
filterset_fields = ["name", "local", "url", "tls_verification", "tls_authentication"]
|
||||||
ordering = ["name"]
|
ordering = ["name"]
|
||||||
|
search_fields = ["name"]
|
||||||
|
|
||||||
|
|
||||||
class KubernetesServiceConnectionSerializer(ServiceConnectionSerializer):
|
class KubernetesServiceConnectionSerializer(ServiceConnectionSerializer):
|
||||||
@ -152,3 +153,4 @@ class KubernetesServiceConnectionViewSet(UsedByMixin, ModelViewSet):
|
|||||||
serializer_class = KubernetesServiceConnectionSerializer
|
serializer_class = KubernetesServiceConnectionSerializer
|
||||||
filterset_fields = ["name", "local"]
|
filterset_fields = ["name", "local"]
|
||||||
ordering = ["name"]
|
ordering = ["name"]
|
||||||
|
search_fields = ["name"]
|
||||||
|
|||||||
@ -2,11 +2,20 @@
|
|||||||
from importlib import import_module
|
from importlib import import_module
|
||||||
|
|
||||||
from django.apps import AppConfig
|
from django.apps import AppConfig
|
||||||
from django.db import ProgrammingError
|
from prometheus_client import Gauge
|
||||||
from structlog.stdlib import get_logger
|
from structlog.stdlib import get_logger
|
||||||
|
|
||||||
LOGGER = get_logger()
|
LOGGER = get_logger()
|
||||||
|
|
||||||
|
GAUGE_OUTPOSTS_CONNECTED = Gauge(
|
||||||
|
"authentik_outposts_connected", "Currently connected outposts", ["outpost", "uid", "expected"]
|
||||||
|
)
|
||||||
|
GAUGE_OUTPOSTS_LAST_UPDATE = Gauge(
|
||||||
|
"authentik_outposts_last_update",
|
||||||
|
"Last update from any outpost",
|
||||||
|
["outpost", "uid", "version"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class AuthentikOutpostConfig(AppConfig):
|
class AuthentikOutpostConfig(AppConfig):
|
||||||
"""authentik outposts app config"""
|
"""authentik outposts app config"""
|
||||||
@ -18,10 +27,3 @@ class AuthentikOutpostConfig(AppConfig):
|
|||||||
def ready(self):
|
def ready(self):
|
||||||
import_module("authentik.outposts.signals")
|
import_module("authentik.outposts.signals")
|
||||||
import_module("authentik.outposts.managed")
|
import_module("authentik.outposts.managed")
|
||||||
try:
|
|
||||||
from authentik.outposts.tasks import outpost_controller_all, outpost_local_connection
|
|
||||||
|
|
||||||
outpost_local_connection.delay()
|
|
||||||
outpost_controller_all.delay()
|
|
||||||
except ProgrammingError:
|
|
||||||
pass
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user