Compare commits
519 Commits
version-20
...
version/20
| Author | SHA1 | Date | |
|---|---|---|---|
| 260a7aac63 | |||
| 37df054f4c | |||
| a3df414f24 | |||
| dcaa8d6322 | |||
| e03dd70f2f | |||
| ceb894039e | |||
| a77616e942 | |||
| 47601a767b | |||
| c7a825c393 | |||
| 181c55aef1 | |||
| 631b1fcc29 | |||
| 54f170650a | |||
| 3bdb551e74 | |||
| 96b2631ec4 | |||
| 4fffa6d2cc | |||
| e46c70e13d | |||
| 7d4e7f84f4 | |||
| d49640ca9b | |||
| ed2cf44471 | |||
| 5b1d15276a | |||
| d9275a3350 | |||
| 2e81dddc1d | |||
| abc73deda0 | |||
| becec6b7d8 | |||
| ab516f782b | |||
| d7b3c545aa | |||
| 81550d9d1d | |||
| 72e5768c2f | |||
| 11cf5fc472 | |||
| fedb81571d | |||
| 37528e1bba | |||
| 97ef2a6f5f | |||
| cc1509cf57 | |||
| 0dfecc6ae2 | |||
| c1e4d78672 | |||
| 0ab427b5bb | |||
| a9f095d1d9 | |||
| de17207c68 | |||
| d9675695fe | |||
| ec7f372fa9 | |||
| 8a675152e6 | |||
| 228fe01f92 | |||
| b9547ece49 | |||
| 6e9bc143bd | |||
| 8cd4bf1be8 | |||
| 76660e4666 | |||
| 73b2e2cb82 | |||
| d741d6dcf1 | |||
| 2575fa6db7 | |||
| 7512c57a2e | |||
| e6e2dfd757 | |||
| 920d1f1b0e | |||
| 680d4fc20d | |||
| 4d3b25ea66 | |||
| 5106c0d0c1 | |||
| fd09ade054 | |||
| 01629fe9e3 | |||
| 5be97e98e4 | |||
| b1fd801ceb | |||
| 62a939b91d | |||
| 257ac04be4 | |||
| ec5e6c14a2 | |||
| 1e1d9f1bdd | |||
| da1ea51dad | |||
| 6ee3b8d644 | |||
| 6155c69b7c | |||
| 136d40d919 | |||
| bb1bb9e22a | |||
| 05e84b63a2 | |||
| 7ab55f7afa | |||
| f5ec5245c5 | |||
| 4f4f954693 | |||
| c57fbcfd89 | |||
| 025fc3fe96 | |||
| 4d079522c4 | |||
| 08acc7ba41 | |||
| 7bdd32506e | |||
| 6283fedcd9 | |||
| 7a0badc81b | |||
| 1e134aa446 | |||
| 27bc5489c5 | |||
| 2dca45917c | |||
| 66a4338b48 | |||
| a4dfc7e068 | |||
| f98a9bed9f | |||
| 5d1bf4a0af | |||
| 34635ab928 | |||
| fabe1130c1 | |||
| 8feda9c2b1 | |||
| 074928cac1 | |||
| 2308f90270 | |||
| 13adca0763 | |||
| 50ded723d1 | |||
| e9064509fe | |||
| 6fdf3ad3e5 | |||
| fb60cefb72 | |||
| 61f7db314a | |||
| ef7952cab3 | |||
| 7e5d8624c8 | |||
| 2c54be85be | |||
| 2f8dbe9b97 | |||
| cebe44403c | |||
| 7261017e13 | |||
| 0b3d33f428 | |||
| 6f0cbd5fa6 | |||
| fb94aefd2f | |||
| c4c8390eff | |||
| 8c2e4478fd | |||
| 94029ee612 | |||
| 8db49f9eca | |||
| 7bd25d90f4 | |||
| 133528ee90 | |||
| 578bd8fcb3 | |||
| 4c2ef95253 | |||
| 702a59222d | |||
| 48e2121a75 | |||
| 61249786ff | |||
| 008af4ccce | |||
| 02e3010efe | |||
| aca4795e0c | |||
| ff0febfecd | |||
| 4daad4b514 | |||
| 677bcaadd7 | |||
| c6e9ecdd37 | |||
| c9ecad6262 | |||
| e545b3b401 | |||
| fec96ea013 | |||
| 1ac1c50b67 | |||
| d2f189c1d0 | |||
| fb33906637 | |||
| 6d3a94f24f | |||
| 84f594e658 | |||
| 1486bd5ab2 | |||
| 2c00f4da2d | |||
| c10a23220b | |||
| f20243d545 | |||
| 903c6422ad | |||
| f5ab955536 | |||
| 3a861f0497 | |||
| 744f250d05 | |||
| 83d435bd3b | |||
| 945cdfe212 | |||
| fcc0963fab | |||
| 2ab4fcd757 | |||
| bfe31b15ad | |||
| 49c4b43f32 | |||
| 19b1f3a8c1 | |||
| 80f218a6bf | |||
| 61aaa90226 | |||
| 7fdda5a387 | |||
| 94597fd2ad | |||
| 09808883f4 | |||
| 81ecb85a55 | |||
| 21bfaa3927 | |||
| 1c9c7be1c0 | |||
| 5a11dc567e | |||
| 4a1acd377b | |||
| c5b84a91d1 | |||
| e77ecda3b8 | |||
| 4e317c10c5 | |||
| eb05a3ddb8 | |||
| a22d6a0924 | |||
| 3f0d67779a | |||
| 0a937ae8e9 | |||
| f8d94f3039 | |||
| 6bb261ac62 | |||
| 45f2c5bae7 | |||
| 5d8c1aa0b0 | |||
| 0101368369 | |||
| 4854f81592 | |||
| 4bed6e02e5 | |||
| 908f123d0e | |||
| 256dd24a1e | |||
| d4284407f9 | |||
| 80da5dfc52 | |||
| b6edf990e0 | |||
| a66dcf9382 | |||
| 9095a840d5 | |||
| 72259f6479 | |||
| 0973c74b9d | |||
| c7ed4f7ac1 | |||
| 3d577cf15e | |||
| 5474a32573 | |||
| a5940b88e3 | |||
| ff15716012 | |||
| c040b13b29 | |||
| 4915e980c5 | |||
| df362dd9ea | |||
| d4e4f93cb4 | |||
| 3af0de6a00 | |||
| 4f24d61290 | |||
| 4c5c4dcf2c | |||
| 660b5cb6c6 | |||
| 6ff1ea73a9 | |||
| 3de224690a | |||
| d4624b510a | |||
| 8856d762d0 | |||
| 5d1cbf14d1 | |||
| 6d5207f644 | |||
| 3b6497cd51 | |||
| ff7320b0f8 | |||
| e5a393c534 | |||
| bb4be944dc | |||
| 21efee8f44 | |||
| f61549a60f | |||
| 0a7bafd1b2 | |||
| b3987c5fa0 | |||
| 0da043a9fe | |||
| f336f204cb | |||
| 3bfcf18492 | |||
| dfafe8b43d | |||
| b5d43b15f8 | |||
| 2ccab75021 | |||
| 9070df6c26 | |||
| a1c8ad55ad | |||
| 872c05c690 | |||
| a9528dc1b5 | |||
| 0e59ade1f2 | |||
| 5ac49c695d | |||
| 3a30ecbe76 | |||
| 1f838bb2aa | |||
| cc42830e23 | |||
| 593eb959ca | |||
| 5bb6785ad6 | |||
| 535c11a729 | |||
| a0fa8d8524 | |||
| c14025c579 | |||
| 8bc3db7c90 | |||
| eaad564e23 | |||
| 511a94975b | |||
| 015810a2fd | |||
| e70e6b84c2 | |||
| d0b9c9a26f | |||
| 3e403fa348 | |||
| 48f4a971ef | |||
| 6314be14ad | |||
| 1a072c6c39 | |||
| ef2eed0bdf | |||
| 91227b1e96 | |||
| 67d68629da | |||
| e875db8f66 | |||
| 055a76393d | |||
| 0754821628 | |||
| fca88d9896 | |||
| dfe0404c51 | |||
| fa61696b46 | |||
| e5773738f4 | |||
| cac8539d79 | |||
| cf600f6f26 | |||
| e194715c3e | |||
| 787f02d5dc | |||
| a0ed01a610 | |||
| 02ba493759 | |||
| a7fea5434d | |||
| 4fb783e953 | |||
| affbf85699 | |||
| 0d92112a3f | |||
| b1ad3ec9db | |||
| c0601baca6 | |||
| 057c5c5e9a | |||
| 05429ab848 | |||
| b66d51a699 | |||
| f834bc0ff2 | |||
| 93fd883d7a | |||
| 7e080d4d68 | |||
| 3e3ca22d04 | |||
| e741caa6b3 | |||
| 4343246a41 | |||
| 3f6f83b4b6 | |||
| c63e1c9b87 | |||
| f44cf06d22 | |||
| 3f609b8601 | |||
| edd89b44a4 | |||
| 3e58748862 | |||
| 7088a6b0e6 | |||
| 6c880e0e62 | |||
| cb1e70be7f | |||
| 6ba150f737 | |||
| 131769ea73 | |||
| e68adbb30d | |||
| f1eef09099 | |||
| 5ab3c7fa9f | |||
| d0cec39a0f | |||
| e15f53a39a | |||
| 25fb995663 | |||
| eac658c64f | |||
| 15e2032493 | |||
| c87f6cd9d9 | |||
| e758995458 | |||
| 20c284a188 | |||
| b0936ea8f3 | |||
| bfc0f4a413 | |||
| 1a9a90cf6a | |||
| 00f1a6fa48 | |||
| 33754a06d2 | |||
| 69b838e1cf | |||
| d5e04a2301 | |||
| fbf251280f | |||
| eaadf62f01 | |||
| 8c33e7a7c1 | |||
| a7d9a80a28 | |||
| 2ea5dce8d3 | |||
| 14bf01efe4 | |||
| 67b24a60e4 | |||
| e6775297cb | |||
| 4e4e2b36b6 | |||
| 3189c56fc3 | |||
| 5b5ea47b7a | |||
| caa382f898 | |||
| 2d63488197 | |||
| c1c8e4c8d4 | |||
| a0e451c5e5 | |||
| eaba8006e6 | |||
| 39ff202f8c | |||
| 654e0d6245 | |||
| ec04443493 | |||
| d247c262af | |||
| dff49b2bef | |||
| 50666a76fb | |||
| b51a7f9746 | |||
| 001dfd9f6c | |||
| 5e4fbeeb25 | |||
| 2c910bf6ca | |||
| 9b11319e81 | |||
| 40dc4b3fb8 | |||
| 0e37b98968 | |||
| 7e132eb014 | |||
| 49dfb4756e | |||
| 814758e2aa | |||
| 5c42dac5e2 | |||
| 88603fa4f7 | |||
| 0232c4e162 | |||
| 11753c1fe1 | |||
| f5cc6c67ec | |||
| 8b8ed3527a | |||
| 1aa0274e7c | |||
| ecd33ca0c1 | |||
| e93be0de9a | |||
| a5adc4f8ed | |||
| a6baed9753 | |||
| ceaf832e63 | |||
| a6b0b14685 | |||
| f679250edd | |||
| acc4de2235 | |||
| 56a8276dbf | |||
| 6dfe6edbef | |||
| 6af4bd0d9a | |||
| 7ee7f6bd6a | |||
| f8b8334010 | |||
| d4b65dc4b4 | |||
| e4bbd3b1c0 | |||
| 87de5e625d | |||
| efbe51673e | |||
| a95bea53ea | |||
| 6021fc0f52 | |||
| 1415b68ff4 | |||
| be6853ac52 | |||
| 7fd6be5abb | |||
| 91d6f572a5 | |||
| 016a9ce34e | |||
| 8adb95af7f | |||
| 1dc54775d8 | |||
| 370ef716b5 | |||
| 16e56ad9ca | |||
| b5b5a9eed3 | |||
| 8b22e7bcc3 | |||
| d48b5b9511 | |||
| 0eccaa3f1e | |||
| 67d550a80d | |||
| ebb5711c32 | |||
| 79ec872232 | |||
| 4284e14ff7 | |||
| 92a09779d0 | |||
| 14c621631d | |||
| c55f503b9b | |||
| a908cad976 | |||
| c2586557d8 | |||
| 01c80a82e2 | |||
| 0d47654651 | |||
| 1183095833 | |||
| c281b11bdc | |||
| 61fe45a58c | |||
| d43aab479c | |||
| 7f8383427a | |||
| a06d6cf33d | |||
| 5b7cb205c9 | |||
| 293a932d20 | |||
| fff901ff03 | |||
| f47c936295 | |||
| 88d5aec618 | |||
| 96ae68cf09 | |||
| 63b3434b6f | |||
| 947ecec02b | |||
| 1c2b452406 | |||
| 47777529ac | |||
| 949095c376 | |||
| 4b112c2799 | |||
| 291a2516b1 | |||
| 4dcfd021e2 | |||
| ca50848db3 | |||
| 0bb3e3c558 | |||
| e4b25809ab | |||
| 7bf932f8e2 | |||
| 99d04528b0 | |||
| e48d172036 | |||
| c2388137a8 | |||
| 650e2cbc38 | |||
| b32800ea71 | |||
| e1c0c0b20c | |||
| fe39e39dcd | |||
| 883f213b03 | |||
| 538996f617 | |||
| 2f4c92deb9 | |||
| ef335ec083 | |||
| 07b09df3fe | |||
| e70e031a1f | |||
| c7ba183dc0 | |||
| 3ed23a37ea | |||
| 3d724db0e3 | |||
| 2997542114 | |||
| 84b18fff96 | |||
| 1dce408c72 | |||
| e5ff47bf14 | |||
| b53bf331c3 | |||
| 90e9a8b34c | |||
| 845f842783 | |||
| 7397849c60 | |||
| 6dd46b5fc5 | |||
| 89ca79ed10 | |||
| 713bef895c | |||
| 925115e9ce | |||
| 42f5cf8c93 | |||
| 82cc1d536a | |||
| 08af2fd46b | |||
| 70e3b27a4d | |||
| 6a411d7960 | |||
| 33567b56d7 | |||
| 0c1954aeb7 | |||
| f4a6c70e98 | |||
| 5f198e7fe4 | |||
| d172d32817 | |||
| af3fb5c2cd | |||
| 885efb526e | |||
| 3bfb8b2cb2 | |||
| 9fc5ff4b77 | |||
| dd8b579dd6 | |||
| e12cbd8711 | |||
| 62d35f8f8c | |||
| 49be504c13 | |||
| edad55e51d | |||
| 38086fa8bb | |||
| c4f9a3e9a7 | |||
| 930df791bd | |||
| 9a6086634c | |||
| b68e65355a | |||
| 72d33a91dd | |||
| 7067e3d69a | |||
| 4db370d24e | |||
| 41e7b9b73f | |||
| 7f47f93e4e | |||
| 89abd44b76 | |||
| 14c7d8c4f4 | |||
| 525976a81b | |||
| 64a2126ea4 | |||
| 994c5882ab | |||
| ad64d51e85 | |||
| a184a7518a | |||
| 943fd80920 | |||
| 01bb18b8c4 | |||
| 94baaaa5a5 | |||
| 40b164ce94 | |||
| 1d7c7801e7 | |||
| 0db0a12ef3 | |||
| 8008aba450 | |||
| eaeab27004 | |||
| 111fbf119b | |||
| 300ad88447 | |||
| 92cc0c9c64 | |||
| 18ff803370 | |||
| 819af78e2b | |||
| 6338785ce1 | |||
| 973e151dff | |||
| fae6d83f27 | |||
| ed84fe0b8d | |||
| 1ee603403e | |||
| 7db7b7cc4d | |||
| 68a98cd86c | |||
| e758db5727 | |||
| 4d7d700afa | |||
| f9a5add01d | |||
| 2986b56389 | |||
| 58f79b525d | |||
| 0a1c0dae05 | |||
| e18ef8dab6 | |||
| 3cacc59bec | |||
| 4eea46d399 | |||
| 11e25617bd | |||
| 4817126811 | |||
| 0181361efa | |||
| 8ff8e1d5f7 | |||
| 19d5902a92 | |||
| 71dffb21a9 | |||
| bd283c506d | |||
| ef564e5f1a | |||
| 2543224c7c | |||
| 077eee9310 | |||
| d894eeaa67 | |||
| 452bfb39bf | |||
| 6b6702521f | |||
| c07b8d95d0 | |||
| bf347730b3 | |||
| ececfc3a30 | |||
| b76546de0c | |||
| 424d490a60 | |||
| 127dd85214 | |||
| 10570ac7f8 | |||
| dc5667b0b8 | |||
| ec9cacb610 | |||
| 0027dbc0e5 |
@ -1,5 +1,5 @@
|
|||||||
[bumpversion]
|
[bumpversion]
|
||||||
current_version = 2021.12.5
|
current_version = 2022.3.2
|
||||||
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+)\-?(?P<release>.*)
|
||||||
|
|||||||
2
.github/stale.yml
vendored
2
.github/stale.yml
vendored
@ -7,7 +7,7 @@ exemptLabels:
|
|||||||
- pinned
|
- pinned
|
||||||
- security
|
- security
|
||||||
- pr_wanted
|
- pr_wanted
|
||||||
- enhancement/confirmed
|
- enhancement
|
||||||
# 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
|
||||||
|
|||||||
65
.github/workflows/ci-main.yml
vendored
65
.github/workflows/ci-main.yml
vendored
@ -31,16 +31,16 @@ jobs:
|
|||||||
- pending-migrations
|
- pending-migrations
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v2
|
- uses: actions/checkout@v3
|
||||||
- uses: actions/setup-python@v2
|
- uses: actions/setup-python@v3
|
||||||
- uses: actions/setup-node@v2
|
- uses: actions/setup-node@v3.0.0
|
||||||
with:
|
with:
|
||||||
node-version: '16'
|
node-version: '16'
|
||||||
- id: cache-poetry
|
- id: cache-poetry
|
||||||
uses: actions/cache@v2.1.7
|
uses: actions/cache@v2.1.7
|
||||||
with:
|
with:
|
||||||
path: ~/.cache/pypoetry/virtualenvs
|
path: ~/.cache/pypoetry/virtualenvs
|
||||||
key: ${{ runner.os }}-poetry-cache-v3-${{ hashFiles('**/poetry.lock') }}
|
key: ${{ runner.os }}-poetry-cache-v2-${{ hashFiles('**/poetry.lock') }}
|
||||||
- name: prepare
|
- name: prepare
|
||||||
env:
|
env:
|
||||||
INSTALL: ${{ steps.cache-poetry.outputs.cache-hit }}
|
INSTALL: ${{ steps.cache-poetry.outputs.cache-hit }}
|
||||||
@ -50,13 +50,13 @@ jobs:
|
|||||||
test-migrations:
|
test-migrations:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v2
|
- uses: actions/checkout@v3
|
||||||
- uses: actions/setup-python@v2
|
- uses: actions/setup-python@v3
|
||||||
- id: cache-poetry
|
- id: cache-poetry
|
||||||
uses: actions/cache@v2.1.7
|
uses: actions/cache@v2.1.7
|
||||||
with:
|
with:
|
||||||
path: ~/.cache/pypoetry/virtualenvs
|
path: ~/.cache/pypoetry/virtualenvs
|
||||||
key: ${{ runner.os }}-poetry-cache-v3-${{ hashFiles('**/poetry.lock') }}
|
key: ${{ runner.os }}-poetry-cache-v2-${{ hashFiles('**/poetry.lock') }}
|
||||||
- name: prepare
|
- name: prepare
|
||||||
env:
|
env:
|
||||||
INSTALL: ${{ steps.cache-poetry.outputs.cache-hit }}
|
INSTALL: ${{ steps.cache-poetry.outputs.cache-hit }}
|
||||||
@ -66,10 +66,10 @@ jobs:
|
|||||||
test-migrations-from-stable:
|
test-migrations-from-stable:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v2
|
- uses: actions/checkout@v3
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
- uses: actions/setup-python@v2
|
- uses: actions/setup-python@v3
|
||||||
- name: prepare variables
|
- name: prepare variables
|
||||||
id: ev
|
id: ev
|
||||||
run: |
|
run: |
|
||||||
@ -79,27 +79,22 @@ jobs:
|
|||||||
uses: actions/cache@v2.1.7
|
uses: actions/cache@v2.1.7
|
||||||
with:
|
with:
|
||||||
path: ~/.cache/pypoetry/virtualenvs
|
path: ~/.cache/pypoetry/virtualenvs
|
||||||
key: ${{ runner.os }}-poetry-cache-v3-${{ hashFiles('**/poetry.lock') }}
|
key: ${{ runner.os }}-poetry-cache-v2-${{ hashFiles('**/poetry.lock') }}
|
||||||
- name: checkout stable
|
- name: checkout stable
|
||||||
run: |
|
run: |
|
||||||
# Copy current, latest config to local
|
# Copy current, latest config to local
|
||||||
cp authentik/lib/default.yml local.env.yml
|
cp authentik/lib/default.yml local.env.yml
|
||||||
cp -R .github ..
|
cp -R .github ..
|
||||||
cp -R scripts ..
|
cp -R scripts ..
|
||||||
cp -R poetry.lock pyproject.toml ..
|
|
||||||
git checkout $(git describe --abbrev=0 --match 'version/*')
|
git checkout $(git describe --abbrev=0 --match 'version/*')
|
||||||
rm -rf .github/ scripts/
|
rm -rf .github/ scripts/
|
||||||
mv ../.github ../scripts ../poetry.lock ../pyproject.toml .
|
mv ../.github ../scripts .
|
||||||
- name: prepare
|
- name: prepare
|
||||||
env:
|
env:
|
||||||
INSTALL: ${{ steps.cache-poetry.outputs.cache-hit }}
|
INSTALL: ${{ steps.cache-poetry.outputs.cache-hit }}
|
||||||
run: |
|
run: |
|
||||||
scripts/ci_prepare.sh
|
scripts/ci_prepare.sh
|
||||||
# Sync anyways since stable will have different dependencies
|
# install anyways since stable will have different dependencies
|
||||||
# TODO: Remove after next stable release
|
|
||||||
if [[ -f "Pipfile.lock" ]]; then
|
|
||||||
pipenv install --dev
|
|
||||||
fi
|
|
||||||
poetry install
|
poetry install
|
||||||
- name: run migrations to stable
|
- name: run migrations to stable
|
||||||
run: poetry run python -m lifecycle.migrate
|
run: poetry run python -m lifecycle.migrate
|
||||||
@ -108,13 +103,7 @@ jobs:
|
|||||||
set -x
|
set -x
|
||||||
git fetch
|
git fetch
|
||||||
git reset --hard HEAD
|
git reset --hard HEAD
|
||||||
# TODO: Remove after next stable release
|
|
||||||
rm -f poetry.lock
|
|
||||||
git checkout $GITHUB_SHA
|
git checkout $GITHUB_SHA
|
||||||
# TODO: Remove after next stable release
|
|
||||||
if [[ -f "Pipfile.lock" ]]; then
|
|
||||||
pipenv install --dev
|
|
||||||
fi
|
|
||||||
poetry install
|
poetry install
|
||||||
- name: prepare
|
- name: prepare
|
||||||
env:
|
env:
|
||||||
@ -125,13 +114,13 @@ jobs:
|
|||||||
test-unittest:
|
test-unittest:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v2
|
- uses: actions/checkout@v3
|
||||||
- uses: actions/setup-python@v2
|
- uses: actions/setup-python@v3
|
||||||
- id: cache-poetry
|
- id: cache-poetry
|
||||||
uses: actions/cache@v2.1.7
|
uses: actions/cache@v2.1.7
|
||||||
with:
|
with:
|
||||||
path: ~/.cache/pypoetry/virtualenvs
|
path: ~/.cache/pypoetry/virtualenvs
|
||||||
key: ${{ runner.os }}-poetry-cache-v3-${{ hashFiles('**/poetry.lock') }}
|
key: ${{ runner.os }}-poetry-cache-v2-${{ hashFiles('**/poetry.lock') }}
|
||||||
- name: prepare
|
- name: prepare
|
||||||
env:
|
env:
|
||||||
INSTALL: ${{ steps.cache-poetry.outputs.cache-hit }}
|
INSTALL: ${{ steps.cache-poetry.outputs.cache-hit }}
|
||||||
@ -152,13 +141,13 @@ jobs:
|
|||||||
test-integration:
|
test-integration:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v2
|
- uses: actions/checkout@v3
|
||||||
- uses: actions/setup-python@v2
|
- uses: actions/setup-python@v3
|
||||||
- id: cache-poetry
|
- id: cache-poetry
|
||||||
uses: actions/cache@v2.1.7
|
uses: actions/cache@v2.1.7
|
||||||
with:
|
with:
|
||||||
path: ~/.cache/pypoetry/virtualenvs
|
path: ~/.cache/pypoetry/virtualenvs
|
||||||
key: ${{ runner.os }}-poetry-cache-v3-${{ hashFiles('**/poetry.lock') }}
|
key: ${{ runner.os }}-poetry-cache-v2-${{ hashFiles('**/poetry.lock') }}
|
||||||
- name: prepare
|
- name: prepare
|
||||||
env:
|
env:
|
||||||
INSTALL: ${{ steps.cache-poetry.outputs.cache-hit }}
|
INSTALL: ${{ steps.cache-poetry.outputs.cache-hit }}
|
||||||
@ -181,9 +170,9 @@ jobs:
|
|||||||
test-e2e-provider:
|
test-e2e-provider:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v2
|
- uses: actions/checkout@v3
|
||||||
- uses: actions/setup-python@v2
|
- uses: actions/setup-python@v3
|
||||||
- uses: actions/setup-node@v2
|
- uses: actions/setup-node@v3.0.0
|
||||||
with:
|
with:
|
||||||
node-version: '16'
|
node-version: '16'
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
@ -195,7 +184,7 @@ jobs:
|
|||||||
uses: actions/cache@v2.1.7
|
uses: actions/cache@v2.1.7
|
||||||
with:
|
with:
|
||||||
path: ~/.cache/pypoetry/virtualenvs
|
path: ~/.cache/pypoetry/virtualenvs
|
||||||
key: ${{ runner.os }}-poetry-cache-v3-${{ hashFiles('**/poetry.lock') }}
|
key: ${{ runner.os }}-poetry-cache-v2-${{ hashFiles('**/poetry.lock') }}
|
||||||
- name: prepare
|
- name: prepare
|
||||||
env:
|
env:
|
||||||
INSTALL: ${{ steps.cache-poetry.outputs.cache-hit }}
|
INSTALL: ${{ steps.cache-poetry.outputs.cache-hit }}
|
||||||
@ -226,9 +215,9 @@ jobs:
|
|||||||
test-e2e-rest:
|
test-e2e-rest:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v2
|
- uses: actions/checkout@v3
|
||||||
- uses: actions/setup-python@v2
|
- uses: actions/setup-python@v3
|
||||||
- uses: actions/setup-node@v2
|
- uses: actions/setup-node@v3.0.0
|
||||||
with:
|
with:
|
||||||
node-version: '16'
|
node-version: '16'
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
@ -240,7 +229,7 @@ jobs:
|
|||||||
uses: actions/cache@v2.1.7
|
uses: actions/cache@v2.1.7
|
||||||
with:
|
with:
|
||||||
path: ~/.cache/pypoetry/virtualenvs
|
path: ~/.cache/pypoetry/virtualenvs
|
||||||
key: ${{ runner.os }}-poetry-cache-v3-${{ hashFiles('**/poetry.lock') }}
|
key: ${{ runner.os }}-poetry-cache-v2-${{ hashFiles('**/poetry.lock') }}
|
||||||
- name: prepare
|
- name: prepare
|
||||||
env:
|
env:
|
||||||
INSTALL: ${{ steps.cache-poetry.outputs.cache-hit }}
|
INSTALL: ${{ steps.cache-poetry.outputs.cache-hit }}
|
||||||
@ -290,7 +279,7 @@ jobs:
|
|||||||
arch:
|
arch:
|
||||||
- 'linux/amd64'
|
- 'linux/amd64'
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v2
|
- uses: actions/checkout@v3
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@v1.2.0
|
uses: docker/setup-qemu-action@v1.2.0
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
|
|||||||
26
.github/workflows/ci-outpost.yml
vendored
26
.github/workflows/ci-outpost.yml
vendored
@ -14,7 +14,7 @@ jobs:
|
|||||||
lint-golint:
|
lint-golint:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v2
|
- uses: actions/checkout@v3
|
||||||
- uses: actions/setup-go@v2
|
- uses: actions/setup-go@v2
|
||||||
with:
|
with:
|
||||||
go-version: "^1.17"
|
go-version: "^1.17"
|
||||||
@ -28,11 +28,27 @@ jobs:
|
|||||||
--rm \
|
--rm \
|
||||||
-v $(pwd):/app \
|
-v $(pwd):/app \
|
||||||
-w /app \
|
-w /app \
|
||||||
golangci/golangci-lint:v1.39.0 \
|
golangci/golangci-lint:v1.43 \
|
||||||
golangci-lint run -v --timeout 200s
|
golangci-lint run -v --timeout 200s
|
||||||
|
test-unittest:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
- uses: actions/setup-go@v2
|
||||||
|
with:
|
||||||
|
go-version: "^1.17"
|
||||||
|
- name: Get dependencies
|
||||||
|
run: |
|
||||||
|
go get github.com/axw/gocov/gocov
|
||||||
|
go get github.com/AlekSi/gocov-xml
|
||||||
|
go get github.com/jstemmer/go-junit-report
|
||||||
|
- name: Go unittests
|
||||||
|
run: |
|
||||||
|
go test -timeout 0 -v -race -coverprofile=coverage.out -covermode=atomic -cover ./... | go-junit-report > junit.xml
|
||||||
ci-outpost-mark:
|
ci-outpost-mark:
|
||||||
needs:
|
needs:
|
||||||
- lint-golint
|
- lint-golint
|
||||||
|
- test-unittest
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- run: echo mark
|
- run: echo mark
|
||||||
@ -50,7 +66,7 @@ jobs:
|
|||||||
- 'linux/amd64'
|
- 'linux/amd64'
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v2
|
- uses: actions/checkout@v3
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@v1.2.0
|
uses: docker/setup-qemu-action@v1.2.0
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
@ -94,11 +110,11 @@ jobs:
|
|||||||
goos: [linux]
|
goos: [linux]
|
||||||
goarch: [amd64, arm64]
|
goarch: [amd64, arm64]
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v2
|
- uses: actions/checkout@v3
|
||||||
- uses: actions/setup-go@v2
|
- uses: actions/setup-go@v2
|
||||||
with:
|
with:
|
||||||
go-version: "^1.17"
|
go-version: "^1.17"
|
||||||
- uses: actions/setup-node@v2
|
- uses: actions/setup-node@v3.0.0
|
||||||
with:
|
with:
|
||||||
node-version: '16'
|
node-version: '16'
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
|
|||||||
16
.github/workflows/ci-web.yml
vendored
16
.github/workflows/ci-web.yml
vendored
@ -14,8 +14,8 @@ jobs:
|
|||||||
lint-eslint:
|
lint-eslint:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v2
|
- uses: actions/checkout@v3
|
||||||
- uses: actions/setup-node@v2
|
- uses: actions/setup-node@v3.0.0
|
||||||
with:
|
with:
|
||||||
node-version: '16'
|
node-version: '16'
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
@ -32,8 +32,8 @@ jobs:
|
|||||||
lint-prettier:
|
lint-prettier:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v2
|
- uses: actions/checkout@v3
|
||||||
- uses: actions/setup-node@v2
|
- uses: actions/setup-node@v3.0.0
|
||||||
with:
|
with:
|
||||||
node-version: '16'
|
node-version: '16'
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
@ -50,8 +50,8 @@ jobs:
|
|||||||
lint-lit-analyse:
|
lint-lit-analyse:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v2
|
- uses: actions/checkout@v3
|
||||||
- uses: actions/setup-node@v2
|
- uses: actions/setup-node@v3.0.0
|
||||||
with:
|
with:
|
||||||
node-version: '16'
|
node-version: '16'
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
@ -78,8 +78,8 @@ jobs:
|
|||||||
- ci-web-mark
|
- ci-web-mark
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v2
|
- uses: actions/checkout@v3
|
||||||
- uses: actions/setup-node@v2
|
- uses: actions/setup-node@v3.0.0
|
||||||
with:
|
with:
|
||||||
node-version: '16'
|
node-version: '16'
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
|
|||||||
2
.github/workflows/codeql-analysis.yml
vendored
2
.github/workflows/codeql-analysis.yml
vendored
@ -28,7 +28,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v2
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
# Initializes the CodeQL tools for scanning.
|
# Initializes the CodeQL tools for scanning.
|
||||||
- name: Initialize CodeQL
|
- name: Initialize CodeQL
|
||||||
|
|||||||
40
.github/workflows/release-publish.yml
vendored
40
.github/workflows/release-publish.yml
vendored
@ -9,7 +9,7 @@ jobs:
|
|||||||
build-server:
|
build-server:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v2
|
- uses: actions/checkout@v3
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@v1.2.0
|
uses: docker/setup-qemu-action@v1.2.0
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
@ -30,21 +30,12 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
push: ${{ github.event_name == 'release' }}
|
push: ${{ github.event_name == 'release' }}
|
||||||
tags: |
|
tags: |
|
||||||
beryju/authentik:2021.12.5,
|
beryju/authentik:2022.3.2,
|
||||||
beryju/authentik:latest,
|
beryju/authentik:latest,
|
||||||
ghcr.io/goauthentik/server:2021.12.5,
|
ghcr.io/goauthentik/server:2022.3.2,
|
||||||
ghcr.io/goauthentik/server:latest
|
ghcr.io/goauthentik/server:latest
|
||||||
platforms: linux/amd64,linux/arm64
|
platforms: linux/amd64,linux/arm64
|
||||||
context: .
|
context: .
|
||||||
- name: Building Docker Image (stable)
|
|
||||||
if: ${{ github.event_name == 'release' && !contains('2021.12.5', 'rc') }}
|
|
||||||
run: |
|
|
||||||
docker pull beryju/authentik:latest
|
|
||||||
docker tag beryju/authentik:latest beryju/authentik:stable
|
|
||||||
docker push beryju/authentik:stable
|
|
||||||
docker pull ghcr.io/goauthentik/server:latest
|
|
||||||
docker tag ghcr.io/goauthentik/server:latest ghcr.io/goauthentik/server:stable
|
|
||||||
docker push ghcr.io/goauthentik/server:stable
|
|
||||||
build-outpost:
|
build-outpost:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
strategy:
|
strategy:
|
||||||
@ -54,7 +45,7 @@ jobs:
|
|||||||
- proxy
|
- proxy
|
||||||
- ldap
|
- ldap
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v2
|
- uses: actions/checkout@v3
|
||||||
- uses: actions/setup-go@v2
|
- uses: actions/setup-go@v2
|
||||||
with:
|
with:
|
||||||
go-version: "^1.17"
|
go-version: "^1.17"
|
||||||
@ -78,21 +69,12 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
push: ${{ github.event_name == 'release' }}
|
push: ${{ github.event_name == 'release' }}
|
||||||
tags: |
|
tags: |
|
||||||
beryju/authentik-${{ matrix.type }}:2021.12.5,
|
beryju/authentik-${{ matrix.type }}:2022.3.2,
|
||||||
beryju/authentik-${{ matrix.type }}:latest,
|
beryju/authentik-${{ matrix.type }}:latest,
|
||||||
ghcr.io/goauthentik/${{ matrix.type }}:2021.12.5,
|
ghcr.io/goauthentik/${{ matrix.type }}:2022.3.2,
|
||||||
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
|
||||||
- name: Building Docker Image (stable)
|
|
||||||
if: ${{ github.event_name == 'release' && !contains('2021.12.5', 'rc') }}
|
|
||||||
run: |
|
|
||||||
docker pull beryju/authentik-${{ matrix.type }}:latest
|
|
||||||
docker tag beryju/authentik-${{ matrix.type }}:latest beryju/authentik-${{ matrix.type }}:stable
|
|
||||||
docker push beryju/authentik-${{ matrix.type }}:stable
|
|
||||||
docker pull ghcr.io/goauthentik/${{ matrix.type }}:latest
|
|
||||||
docker tag ghcr.io/goauthentik/${{ matrix.type }}:latest ghcr.io/goauthentik/${{ matrix.type }}:stable
|
|
||||||
docker push ghcr.io/goauthentik/${{ matrix.type }}:stable
|
|
||||||
build-outpost-binary:
|
build-outpost-binary:
|
||||||
timeout-minutes: 120
|
timeout-minutes: 120
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@ -105,11 +87,11 @@ jobs:
|
|||||||
goos: [linux, darwin]
|
goos: [linux, darwin]
|
||||||
goarch: [amd64, arm64]
|
goarch: [amd64, arm64]
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v2
|
- uses: actions/checkout@v3
|
||||||
- uses: actions/setup-go@v2
|
- uses: actions/setup-go@v2
|
||||||
with:
|
with:
|
||||||
go-version: "^1.17"
|
go-version: "^1.17"
|
||||||
- uses: actions/setup-node@v2
|
- uses: actions/setup-node@v3.0.0
|
||||||
with:
|
with:
|
||||||
node-version: '16'
|
node-version: '16'
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
@ -139,7 +121,7 @@ jobs:
|
|||||||
- build-outpost-binary
|
- build-outpost-binary
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v2
|
- uses: actions/checkout@v3
|
||||||
- name: Run test suite in final docker images
|
- name: Run test suite in final docker images
|
||||||
run: |
|
run: |
|
||||||
echo "PG_PASS=$(openssl rand -base64 32)" >> .env
|
echo "PG_PASS=$(openssl rand -base64 32)" >> .env
|
||||||
@ -155,7 +137,7 @@ jobs:
|
|||||||
- build-outpost-binary
|
- build-outpost-binary
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v2
|
- uses: actions/checkout@v3
|
||||||
- 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
|
||||||
@ -170,7 +152,7 @@ jobs:
|
|||||||
SENTRY_PROJECT: authentik
|
SENTRY_PROJECT: authentik
|
||||||
SENTRY_URL: https://sentry.beryju.org
|
SENTRY_URL: https://sentry.beryju.org
|
||||||
with:
|
with:
|
||||||
version: authentik@2021.12.5
|
version: authentik@2022.3.2
|
||||||
environment: beryjuorg-prod
|
environment: beryjuorg-prod
|
||||||
sourcemaps: './web/dist'
|
sourcemaps: './web/dist'
|
||||||
url_prefix: '~/static/dist'
|
url_prefix: '~/static/dist'
|
||||||
|
|||||||
4
.github/workflows/release-tag.yml
vendored
4
.github/workflows/release-tag.yml
vendored
@ -10,7 +10,7 @@ jobs:
|
|||||||
name: Create Release from Tag
|
name: Create Release from Tag
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v2
|
- uses: actions/checkout@v3
|
||||||
- name: Pre-release test
|
- name: Pre-release test
|
||||||
run: |
|
run: |
|
||||||
echo "PG_PASS=$(openssl rand -base64 32)" >> .env
|
echo "PG_PASS=$(openssl rand -base64 32)" >> .env
|
||||||
@ -27,7 +27,7 @@ jobs:
|
|||||||
docker-compose run -u root server test
|
docker-compose run -u root server test
|
||||||
- name: Extract version number
|
- name: Extract version number
|
||||||
id: get_version
|
id: get_version
|
||||||
uses: actions/github-script@v5
|
uses: actions/github-script@v6
|
||||||
with:
|
with:
|
||||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
script: |
|
script: |
|
||||||
|
|||||||
6
.github/workflows/translation-compile.yml
vendored
6
.github/workflows/translation-compile.yml
vendored
@ -20,13 +20,13 @@ jobs:
|
|||||||
compile:
|
compile:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v2
|
- uses: actions/checkout@v3
|
||||||
- uses: actions/setup-python@v2
|
- uses: actions/setup-python@v3
|
||||||
- id: cache-poetry
|
- id: cache-poetry
|
||||||
uses: actions/cache@v2.1.7
|
uses: actions/cache@v2.1.7
|
||||||
with:
|
with:
|
||||||
path: ~/.cache/pypoetry/virtualenvs
|
path: ~/.cache/pypoetry/virtualenvs
|
||||||
key: ${{ runner.os }}-poetry-cache-v3-${{ hashFiles('**/poetry.lock') }}
|
key: ${{ runner.os }}-poetry-cache-v2-${{ hashFiles('**/poetry.lock') }}
|
||||||
- name: prepare
|
- name: prepare
|
||||||
env:
|
env:
|
||||||
INSTALL: ${{ steps.cache-poetry.outputs.cache-hit }}
|
INSTALL: ${{ steps.cache-poetry.outputs.cache-hit }}
|
||||||
|
|||||||
4
.github/workflows/web-api-publish.yml
vendored
4
.github/workflows/web-api-publish.yml
vendored
@ -8,9 +8,9 @@ jobs:
|
|||||||
build:
|
build:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v2
|
- uses: actions/checkout@v3
|
||||||
# Setup .npmrc file to publish to npm
|
# Setup .npmrc file to publish to npm
|
||||||
- uses: actions/setup-node@v2
|
- uses: actions/setup-node@v3.0.0
|
||||||
with:
|
with:
|
||||||
node-version: '16'
|
node-version: '16'
|
||||||
registry-url: 'https://registry.npmjs.org'
|
registry-url: 'https://registry.npmjs.org'
|
||||||
|
|||||||
@ -1 +0,0 @@
|
|||||||
3.9.7
|
|
||||||
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
@ -12,7 +12,8 @@
|
|||||||
"totp",
|
"totp",
|
||||||
"webauthn",
|
"webauthn",
|
||||||
"traefik",
|
"traefik",
|
||||||
"passwordless"
|
"passwordless",
|
||||||
|
"kubernetes"
|
||||||
],
|
],
|
||||||
"python.linting.pylintEnabled": true,
|
"python.linting.pylintEnabled": true,
|
||||||
"todo-tree.tree.showCountsInTree": true,
|
"todo-tree.tree.showCountsInTree": true,
|
||||||
|
|||||||
@ -16,7 +16,7 @@ ENV NODE_ENV=production
|
|||||||
RUN cd /work/web && npm i && npm run build
|
RUN cd /work/web && npm i && npm run build
|
||||||
|
|
||||||
# Stage 3: Build go proxy
|
# Stage 3: Build go proxy
|
||||||
FROM docker.io/golang:1.17.5-bullseye AS builder
|
FROM docker.io/golang:1.17.8-bullseye AS builder
|
||||||
|
|
||||||
WORKDIR /work
|
WORKDIR /work
|
||||||
|
|
||||||
@ -32,7 +32,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 4: Run
|
# Stage 4: Run
|
||||||
FROM docker.io/python:3.10.1-slim-bullseye
|
FROM docker.io/python:3.10.2-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.
|
||||||
@ -60,9 +60,9 @@ RUN apt-get update && \
|
|||||||
apt-get clean && \
|
apt-get clean && \
|
||||||
rm -rf /tmp/* /var/lib/apt/lists/* /var/tmp/ && \
|
rm -rf /tmp/* /var/lib/apt/lists/* /var/tmp/ && \
|
||||||
adduser --system --no-create-home --uid 1000 --group --home /authentik authentik && \
|
adduser --system --no-create-home --uid 1000 --group --home /authentik authentik && \
|
||||||
mkdir -p /backups /certs /media && \
|
mkdir -p /certs /media && \
|
||||||
mkdir -p /authentik/.ssh && \
|
mkdir -p /authentik/.ssh && \
|
||||||
chown authentik:authentik /backups /certs /media /authentik/.ssh
|
chown authentik:authentik /certs /media /authentik/.ssh
|
||||||
|
|
||||||
COPY ./authentik/ /authentik
|
COPY ./authentik/ /authentik
|
||||||
COPY ./pyproject.toml /
|
COPY ./pyproject.toml /
|
||||||
|
|||||||
13
Makefile
13
Makefile
@ -15,6 +15,9 @@ test-e2e-provider:
|
|||||||
test-e2e-rest:
|
test-e2e-rest:
|
||||||
coverage run manage.py test tests/e2e/test_flows* tests/e2e/test_source*
|
coverage run manage.py test tests/e2e/test_flows* tests/e2e/test_source*
|
||||||
|
|
||||||
|
test-go:
|
||||||
|
go test -timeout 0 -v -race -cover ./...
|
||||||
|
|
||||||
test:
|
test:
|
||||||
coverage run manage.py test authentik
|
coverage run manage.py test authentik
|
||||||
coverage html
|
coverage html
|
||||||
@ -127,3 +130,13 @@ ci-pyright: ci--meta-debug
|
|||||||
|
|
||||||
ci-pending-migrations: ci--meta-debug
|
ci-pending-migrations: ci--meta-debug
|
||||||
./manage.py makemigrations --check
|
./manage.py makemigrations --check
|
||||||
|
|
||||||
|
install:
|
||||||
|
poetry install
|
||||||
|
cd web && npm i
|
||||||
|
cd website && npm i
|
||||||
|
|
||||||
|
a: install
|
||||||
|
tmux -CC \
|
||||||
|
new-session 'make run' \; \
|
||||||
|
split-window 'make web-watch'
|
||||||
|
|||||||
@ -57,4 +57,4 @@ DigitalOcean provides development and testing resources for authentik.
|
|||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
Netlify hosts the [goauthentik.io](goauthentik.io) site.
|
Netlify hosts the [goauthentik.io](https://goauthentik.io) site.
|
||||||
|
|||||||
@ -6,8 +6,8 @@
|
|||||||
|
|
||||||
| Version | Supported |
|
| Version | Supported |
|
||||||
| ---------- | ------------------ |
|
| ---------- | ------------------ |
|
||||||
| 2021.10.x | :white_check_mark: |
|
| 2022.2.x | :white_check_mark: |
|
||||||
| 2021.12.x | :white_check_mark: |
|
| 2022.3.x | :white_check_mark: |
|
||||||
|
|
||||||
## Reporting a Vulnerability
|
## Reporting a Vulnerability
|
||||||
|
|
||||||
|
|||||||
@ -1,3 +1,19 @@
|
|||||||
"""authentik"""
|
"""authentik"""
|
||||||
__version__ = "2021.12.5"
|
from os import environ
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
__version__ = "2022.3.2"
|
||||||
ENV_GIT_HASH_KEY = "GIT_BUILD_HASH"
|
ENV_GIT_HASH_KEY = "GIT_BUILD_HASH"
|
||||||
|
|
||||||
|
|
||||||
|
def get_build_hash(fallback: Optional[str] = None) -> str:
|
||||||
|
"""Get build hash"""
|
||||||
|
return environ.get(ENV_GIT_HASH_KEY, fallback if fallback else "")
|
||||||
|
|
||||||
|
|
||||||
|
def get_full_version() -> str:
|
||||||
|
"""Get full version, with build hash appended"""
|
||||||
|
version = __version__
|
||||||
|
if (build_hash := get_build_hash()) != "":
|
||||||
|
version += "." + build_hash
|
||||||
|
return version
|
||||||
|
|||||||
@ -12,10 +12,13 @@ from rest_framework.permissions import IsAdminUser
|
|||||||
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.viewsets import ViewSet
|
from rest_framework.viewsets import ViewSet
|
||||||
|
from structlog.stdlib import get_logger
|
||||||
|
|
||||||
from authentik.core.api.utils import PassiveSerializer
|
from authentik.core.api.utils import PassiveSerializer
|
||||||
from authentik.events.monitored_tasks import TaskInfo, TaskResultStatus
|
from authentik.events.monitored_tasks import TaskInfo, TaskResultStatus
|
||||||
|
|
||||||
|
LOGGER = get_logger()
|
||||||
|
|
||||||
|
|
||||||
class TaskSerializer(PassiveSerializer):
|
class TaskSerializer(PassiveSerializer):
|
||||||
"""Serialize TaskInfo and TaskResult"""
|
"""Serialize TaskInfo and TaskResult"""
|
||||||
@ -89,6 +92,7 @@ class TaskViewSet(ViewSet):
|
|||||||
try:
|
try:
|
||||||
task_module = import_module(task.task_call_module)
|
task_module = import_module(task.task_call_module)
|
||||||
task_func = getattr(task_module, task.task_call_func)
|
task_func = getattr(task_module, task.task_call_func)
|
||||||
|
LOGGER.debug("Running task", task=task_func)
|
||||||
task_func.delay(*task.task_call_args, **task.task_call_kwargs)
|
task_func.delay(*task.task_call_args, **task.task_call_kwargs)
|
||||||
messages.success(
|
messages.success(
|
||||||
self.request,
|
self.request,
|
||||||
@ -96,6 +100,7 @@ class TaskViewSet(ViewSet):
|
|||||||
)
|
)
|
||||||
return Response(status=204)
|
return Response(status=204)
|
||||||
except (ImportError, AttributeError): # pragma: no cover
|
except (ImportError, AttributeError): # pragma: no cover
|
||||||
|
LOGGER.warning("Failed to run task, remove state", task=task)
|
||||||
# if we get an import error, the module path has probably changed
|
# if we get an import error, the module path has probably changed
|
||||||
task.delete()
|
task.delete()
|
||||||
return Response(status=500)
|
return Response(status=500)
|
||||||
|
|||||||
@ -1,6 +1,4 @@
|
|||||||
"""authentik administration overview"""
|
"""authentik administration overview"""
|
||||||
from os import environ
|
|
||||||
|
|
||||||
from django.core.cache import cache
|
from django.core.cache import cache
|
||||||
from drf_spectacular.utils import extend_schema
|
from drf_spectacular.utils import extend_schema
|
||||||
from packaging.version import parse
|
from packaging.version import parse
|
||||||
@ -10,7 +8,7 @@ from rest_framework.request import Request
|
|||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
from rest_framework.views import APIView
|
from rest_framework.views import APIView
|
||||||
|
|
||||||
from authentik import ENV_GIT_HASH_KEY, __version__
|
from authentik import __version__, get_build_hash
|
||||||
from authentik.admin.tasks import VERSION_CACHE_KEY, update_latest_version
|
from authentik.admin.tasks import VERSION_CACHE_KEY, update_latest_version
|
||||||
from authentik.core.api.utils import PassiveSerializer
|
from authentik.core.api.utils import PassiveSerializer
|
||||||
|
|
||||||
@ -25,7 +23,7 @@ class VersionSerializer(PassiveSerializer):
|
|||||||
|
|
||||||
def get_build_hash(self, _) -> str:
|
def get_build_hash(self, _) -> str:
|
||||||
"""Get build hash, if version is not latest or released"""
|
"""Get build hash, if version is not latest or released"""
|
||||||
return environ.get(ENV_GIT_HASH_KEY, "")
|
return get_build_hash()
|
||||||
|
|
||||||
def get_version_current(self, _) -> str:
|
def get_version_current(self, _) -> str:
|
||||||
"""Get current version"""
|
"""Get current version"""
|
||||||
|
|||||||
@ -1,4 +1,6 @@
|
|||||||
"""authentik admin app config"""
|
"""authentik admin app config"""
|
||||||
|
from importlib import import_module
|
||||||
|
|
||||||
from django.apps import AppConfig
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
@ -13,3 +15,4 @@ class AuthentikAdminConfig(AppConfig):
|
|||||||
from authentik.admin.tasks import clear_update_notifications
|
from authentik.admin.tasks import clear_update_notifications
|
||||||
|
|
||||||
clear_update_notifications.delay()
|
clear_update_notifications.delay()
|
||||||
|
import_module("authentik.admin.signals")
|
||||||
|
|||||||
23
authentik/admin/signals.py
Normal file
23
authentik/admin/signals.py
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
"""admin signals"""
|
||||||
|
from django.dispatch import receiver
|
||||||
|
|
||||||
|
from authentik.admin.api.tasks import TaskInfo
|
||||||
|
from authentik.admin.api.workers import GAUGE_WORKERS
|
||||||
|
from authentik.root.celery import CELERY_APP
|
||||||
|
from authentik.root.monitoring import monitoring_set
|
||||||
|
|
||||||
|
|
||||||
|
@receiver(monitoring_set)
|
||||||
|
# pylint: disable=unused-argument
|
||||||
|
def monitoring_set_workers(sender, **kwargs):
|
||||||
|
"""Set worker gauge"""
|
||||||
|
count = len(CELERY_APP.control.ping(timeout=0.5))
|
||||||
|
GAUGE_WORKERS.set(count)
|
||||||
|
|
||||||
|
|
||||||
|
@receiver(monitoring_set)
|
||||||
|
# pylint: disable=unused-argument
|
||||||
|
def monitoring_set_tasks(sender, **kwargs):
|
||||||
|
"""Set task gauges"""
|
||||||
|
for task in TaskInfo.all().values():
|
||||||
|
task.set_prom_metrics()
|
||||||
@ -1,6 +1,5 @@
|
|||||||
"""authentik admin tasks"""
|
"""authentik admin tasks"""
|
||||||
import re
|
import re
|
||||||
from os import environ
|
|
||||||
|
|
||||||
from django.core.cache import cache
|
from django.core.cache import cache
|
||||||
from django.core.validators import URLValidator
|
from django.core.validators import URLValidator
|
||||||
@ -9,7 +8,7 @@ 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 ENV_GIT_HASH_KEY, __version__
|
from authentik import __version__, get_build_hash
|
||||||
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,
|
||||||
@ -36,7 +35,7 @@ def _set_prom_info():
|
|||||||
{
|
{
|
||||||
"version": __version__,
|
"version": __version__,
|
||||||
"latest": cache.get(VERSION_CACHE_KEY, ""),
|
"latest": cache.get(VERSION_CACHE_KEY, ""),
|
||||||
"build_hash": environ.get(ENV_GIT_HASH_KEY, ""),
|
"build_hash": get_build_hash(),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@ -30,7 +30,7 @@ function getCookie(name) {
|
|||||||
window.addEventListener('DOMContentLoaded', (event) => {
|
window.addEventListener('DOMContentLoaded', (event) => {
|
||||||
const rapidocEl = document.querySelector('rapi-doc');
|
const rapidocEl = document.querySelector('rapi-doc');
|
||||||
rapidocEl.addEventListener('before-try', (e) => {
|
rapidocEl.addEventListener('before-try', (e) => {
|
||||||
e.detail.request.headers.append('X-CSRFToken', getCookie("authentik_csrf"));
|
e.detail.request.headers.append('X-authentik-CSRF', getCookie("authentik_csrf"));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -4,7 +4,5 @@ from django.urls import include, path
|
|||||||
from authentik.api.v3.urls import urlpatterns as v3_urls
|
from authentik.api.v3.urls import urlpatterns as v3_urls
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
# TODO: Remove in 2022.1
|
|
||||||
path("v2beta/", include(v3_urls)),
|
|
||||||
path("v3/", include(v3_urls)),
|
path("v3/", include(v3_urls)),
|
||||||
]
|
]
|
||||||
|
|||||||
@ -1,10 +1,9 @@
|
|||||||
"""core Configs API"""
|
"""core Configs API"""
|
||||||
from os import environ, path
|
from os import path
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from drf_spectacular.utils import extend_schema
|
from drf_spectacular.utils import extend_schema
|
||||||
from kubernetes.config.incluster_config import SERVICE_HOST_ENV_NAME
|
|
||||||
from rest_framework.fields import (
|
from rest_framework.fields import (
|
||||||
BooleanField,
|
BooleanField,
|
||||||
CharField,
|
CharField,
|
||||||
@ -28,7 +27,6 @@ class Capabilities(models.TextChoices):
|
|||||||
|
|
||||||
CAN_SAVE_MEDIA = "can_save_media"
|
CAN_SAVE_MEDIA = "can_save_media"
|
||||||
CAN_GEO_IP = "can_geo_ip"
|
CAN_GEO_IP = "can_geo_ip"
|
||||||
CAN_BACKUP = "can_backup"
|
|
||||||
|
|
||||||
|
|
||||||
class ErrorReportingConfigSerializer(PassiveSerializer):
|
class ErrorReportingConfigSerializer(PassiveSerializer):
|
||||||
@ -65,13 +63,6 @@ class ConfigView(APIView):
|
|||||||
caps.append(Capabilities.CAN_SAVE_MEDIA)
|
caps.append(Capabilities.CAN_SAVE_MEDIA)
|
||||||
if GEOIP_READER.enabled:
|
if GEOIP_READER.enabled:
|
||||||
caps.append(Capabilities.CAN_GEO_IP)
|
caps.append(Capabilities.CAN_GEO_IP)
|
||||||
if SERVICE_HOST_ENV_NAME in environ:
|
|
||||||
# Running in k8s, only s3 backup is supported
|
|
||||||
if CONFIG.y("postgresql.s3_backup"):
|
|
||||||
caps.append(Capabilities.CAN_BACKUP)
|
|
||||||
else:
|
|
||||||
# Running in compose, backup is always supported
|
|
||||||
caps.append(Capabilities.CAN_BACKUP)
|
|
||||||
return caps
|
return caps
|
||||||
|
|
||||||
@extend_schema(responses={200: ConfigSerializer(many=False)})
|
@extend_schema(responses={200: ConfigSerializer(many=False)})
|
||||||
@ -80,7 +71,7 @@ class ConfigView(APIView):
|
|||||||
config = ConfigSerializer(
|
config = ConfigSerializer(
|
||||||
{
|
{
|
||||||
"error_reporting": {
|
"error_reporting": {
|
||||||
"enabled": CONFIG.y("error_reporting.enabled"),
|
"enabled": CONFIG.y("error_reporting.enabled") and not settings.DEBUG,
|
||||||
"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)),
|
||||||
|
|||||||
@ -1,4 +1,6 @@
|
|||||||
"""Application API Views"""
|
"""Application API Views"""
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
from django.core.cache import cache
|
from django.core.cache import cache
|
||||||
from django.db.models import QuerySet
|
from django.db.models import QuerySet
|
||||||
from django.http.response import HttpResponseBadRequest
|
from django.http.response import HttpResponseBadRequest
|
||||||
@ -7,7 +9,7 @@ from drf_spectacular.types import OpenApiTypes
|
|||||||
from drf_spectacular.utils import OpenApiParameter, OpenApiResponse, extend_schema
|
from drf_spectacular.utils import OpenApiParameter, OpenApiResponse, extend_schema
|
||||||
from guardian.shortcuts import get_objects_for_user
|
from guardian.shortcuts import get_objects_for_user
|
||||||
from rest_framework.decorators import action
|
from rest_framework.decorators import action
|
||||||
from rest_framework.fields import ReadOnlyField
|
from rest_framework.fields import ReadOnlyField, SerializerMethodField
|
||||||
from rest_framework.parsers import MultiPartParser
|
from rest_framework.parsers import MultiPartParser
|
||||||
from rest_framework.request import Request
|
from rest_framework.request import Request
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
@ -39,11 +41,16 @@ def user_app_cache_key(user_pk: str) -> str:
|
|||||||
class ApplicationSerializer(ModelSerializer):
|
class ApplicationSerializer(ModelSerializer):
|
||||||
"""Application Serializer"""
|
"""Application Serializer"""
|
||||||
|
|
||||||
launch_url = ReadOnlyField(source="get_launch_url")
|
launch_url = SerializerMethodField()
|
||||||
provider_obj = ProviderSerializer(source="get_provider", required=False)
|
provider_obj = ProviderSerializer(source="get_provider", required=False)
|
||||||
|
|
||||||
meta_icon = ReadOnlyField(source="get_meta_icon")
|
meta_icon = ReadOnlyField(source="get_meta_icon")
|
||||||
|
|
||||||
|
def get_launch_url(self, app: Application) -> Optional[str]:
|
||||||
|
"""Allow formatting of launch URL"""
|
||||||
|
user = self.context["request"].user
|
||||||
|
return app.get_launch_url(user)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
|
||||||
model = Application
|
model = Application
|
||||||
|
|||||||
@ -1,10 +1,9 @@
|
|||||||
"""Tokens API Viewset"""
|
"""Tokens API Viewset"""
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from django.http.response import Http404
|
|
||||||
from django_filters.rest_framework import DjangoFilterBackend
|
from django_filters.rest_framework import DjangoFilterBackend
|
||||||
from drf_spectacular.utils import OpenApiResponse, extend_schema
|
from drf_spectacular.utils import OpenApiResponse, extend_schema
|
||||||
from guardian.shortcuts import get_anonymous_user
|
from guardian.shortcuts import assign_perm, get_anonymous_user
|
||||||
from rest_framework.decorators import action
|
from rest_framework.decorators import action
|
||||||
from rest_framework.exceptions import ValidationError
|
from rest_framework.exceptions import ValidationError
|
||||||
from rest_framework.fields import CharField
|
from rest_framework.fields import CharField
|
||||||
@ -96,10 +95,12 @@ class TokenViewSet(UsedByMixin, ModelViewSet):
|
|||||||
|
|
||||||
def perform_create(self, serializer: TokenSerializer):
|
def perform_create(self, serializer: TokenSerializer):
|
||||||
if not self.request.user.is_superuser:
|
if not self.request.user.is_superuser:
|
||||||
return serializer.save(
|
instance = serializer.save(
|
||||||
user=self.request.user,
|
user=self.request.user,
|
||||||
expiring=self.request.user.attributes.get(USER_ATTRIBUTE_TOKEN_EXPIRING, True),
|
expiring=self.request.user.attributes.get(USER_ATTRIBUTE_TOKEN_EXPIRING, True),
|
||||||
)
|
)
|
||||||
|
assign_perm("authentik_core.view_token_key", self.request.user, instance)
|
||||||
|
return instance
|
||||||
return super().perform_create(serializer)
|
return super().perform_create(serializer)
|
||||||
|
|
||||||
@permission_required("authentik_core.view_token_key")
|
@permission_required("authentik_core.view_token_key")
|
||||||
@ -114,7 +115,5 @@ class TokenViewSet(UsedByMixin, ModelViewSet):
|
|||||||
def view_key(self, request: Request, identifier: str) -> Response:
|
def view_key(self, request: Request, identifier: str) -> Response:
|
||||||
"""Return token key and log access"""
|
"""Return token key and log access"""
|
||||||
token: Token = self.get_object()
|
token: Token = self.get_object()
|
||||||
if token.is_expired:
|
|
||||||
raise Http404
|
|
||||||
Event.new(EventAction.SECRET_VIEW, secret=token).from_http(request) # noqa # nosec
|
Event.new(EventAction.SECRET_VIEW, secret=token).from_http(request) # noqa # nosec
|
||||||
return Response(TokenViewSerializer({"key": token.key}).data)
|
return Response(TokenViewSerializer({"key": token.key}).data)
|
||||||
|
|||||||
@ -24,7 +24,6 @@ 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, DictField, JSONField, SerializerMethodField
|
from rest_framework.fields import CharField, DictField, JSONField, SerializerMethodField
|
||||||
from rest_framework.permissions import IsAuthenticated
|
|
||||||
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 (
|
||||||
@ -46,9 +45,6 @@ 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_IMPERSONATE_ORIGINAL_USER, SESSION_IMPERSONATE_USER
|
||||||
from authentik.core.models import (
|
from authentik.core.models import (
|
||||||
USER_ATTRIBUTE_CHANGE_EMAIL,
|
|
||||||
USER_ATTRIBUTE_CHANGE_NAME,
|
|
||||||
USER_ATTRIBUTE_CHANGE_USERNAME,
|
|
||||||
USER_ATTRIBUTE_SA,
|
USER_ATTRIBUTE_SA,
|
||||||
USER_ATTRIBUTE_TOKEN_EXPIRING,
|
USER_ATTRIBUTE_TOKEN_EXPIRING,
|
||||||
Group,
|
Group,
|
||||||
@ -57,7 +53,6 @@ from authentik.core.models import (
|
|||||||
User,
|
User,
|
||||||
)
|
)
|
||||||
from authentik.events.models import EventAction
|
from authentik.events.models import EventAction
|
||||||
from authentik.lib.config import CONFIG
|
|
||||||
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
|
||||||
@ -126,43 +121,6 @@ class UserSelfSerializer(ModelSerializer):
|
|||||||
"pk": group.pk,
|
"pk": group.pk,
|
||||||
}
|
}
|
||||||
|
|
||||||
def validate_email(self, email: str):
|
|
||||||
"""Check if the user is allowed to change their email"""
|
|
||||||
if self.instance.group_attributes().get(
|
|
||||||
USER_ATTRIBUTE_CHANGE_EMAIL, CONFIG.y_bool("default_user_change_email", True)
|
|
||||||
):
|
|
||||||
return email
|
|
||||||
if email != self.instance.email:
|
|
||||||
raise ValidationError("Not allowed to change email.")
|
|
||||||
return email
|
|
||||||
|
|
||||||
def validate_name(self, name: str):
|
|
||||||
"""Check if the user is allowed to change their name"""
|
|
||||||
if self.instance.group_attributes().get(
|
|
||||||
USER_ATTRIBUTE_CHANGE_NAME, CONFIG.y_bool("default_user_change_name", True)
|
|
||||||
):
|
|
||||||
return name
|
|
||||||
if name != self.instance.name:
|
|
||||||
raise ValidationError("Not allowed to change name.")
|
|
||||||
return name
|
|
||||||
|
|
||||||
def validate_username(self, username: str):
|
|
||||||
"""Check if the user is allowed to change their username"""
|
|
||||||
if self.instance.group_attributes().get(
|
|
||||||
USER_ATTRIBUTE_CHANGE_USERNAME, CONFIG.y_bool("default_user_change_username", True)
|
|
||||||
):
|
|
||||||
return username
|
|
||||||
if username != self.instance.username:
|
|
||||||
raise ValidationError("Not allowed to change username.")
|
|
||||||
return username
|
|
||||||
|
|
||||||
def save(self, **kwargs):
|
|
||||||
if self.instance:
|
|
||||||
attributes: dict = self.instance.attributes
|
|
||||||
attributes.update(self.validated_data.get("attributes", {}))
|
|
||||||
self.validated_data["attributes"] = attributes
|
|
||||||
return super().save(**kwargs)
|
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
|
||||||
model = User
|
model = User
|
||||||
@ -241,6 +199,7 @@ 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")
|
||||||
|
|
||||||
groups_by_name = ModelMultipleChoiceFilter(
|
groups_by_name = ModelMultipleChoiceFilter(
|
||||||
field_name="ak_groups__name",
|
field_name="ak_groups__name",
|
||||||
@ -290,7 +249,7 @@ class UserViewSet(UsedByMixin, ModelViewSet):
|
|||||||
queryset = User.objects.none()
|
queryset = User.objects.none()
|
||||||
ordering = ["username"]
|
ordering = ["username"]
|
||||||
serializer_class = UserSerializer
|
serializer_class = UserSerializer
|
||||||
search_fields = ["username", "name", "is_active", "email"]
|
search_fields = ["username", "name", "is_active", "email", "uuid"]
|
||||||
filterset_class = UsersFilter
|
filterset_class = UsersFilter
|
||||||
|
|
||||||
def get_queryset(self): # pragma: no cover
|
def get_queryset(self): # pragma: no cover
|
||||||
@ -407,26 +366,6 @@ class UserViewSet(UsedByMixin, ModelViewSet):
|
|||||||
update_session_auth_hash(self.request, user)
|
update_session_auth_hash(self.request, user)
|
||||||
return Response(status=204)
|
return Response(status=204)
|
||||||
|
|
||||||
@extend_schema(request=UserSelfSerializer, responses={200: SessionUserSerializer(many=False)})
|
|
||||||
@action(
|
|
||||||
methods=["PUT"],
|
|
||||||
detail=False,
|
|
||||||
pagination_class=None,
|
|
||||||
filter_backends=[],
|
|
||||||
permission_classes=[IsAuthenticated],
|
|
||||||
)
|
|
||||||
def update_self(self, request: Request) -> Response:
|
|
||||||
"""Allow users to change information on their own profile"""
|
|
||||||
data = UserSelfSerializer(instance=User.objects.get(pk=request.user.pk), data=request.data)
|
|
||||||
if not data.is_valid():
|
|
||||||
return Response(data.errors, status=400)
|
|
||||||
new_user = data.save()
|
|
||||||
# If we're impersonating, we need to update that user object
|
|
||||||
# since it caches the full object
|
|
||||||
if SESSION_IMPERSONATE_USER in request.session:
|
|
||||||
request.session[SESSION_IMPERSONATE_USER] = new_user
|
|
||||||
return Response({"user": data.data})
|
|
||||||
|
|
||||||
@permission_required("authentik_core.view_user", ["authentik_events.view_event"])
|
@permission_required("authentik_core.view_user", ["authentik_events.view_event"])
|
||||||
@extend_schema(responses={200: UserMetricsSerializer(many=False)})
|
@extend_schema(responses={200: UserMetricsSerializer(many=False)})
|
||||||
@action(detail=True, pagination_class=None, filter_backends=[])
|
@action(detail=True, pagination_class=None, filter_backends=[])
|
||||||
|
|||||||
@ -30,7 +30,7 @@ class InbuiltBackend(ModelBackend):
|
|||||||
return
|
return
|
||||||
# Since we can't directly pass other variables to signals, and we want to log the method
|
# Since we can't directly pass other variables to signals, and we want to log the method
|
||||||
# and the token used, we assume we're running in a flow and set a variable in the context
|
# and the token used, we assume we're running in a flow and set a variable in the context
|
||||||
flow_plan: FlowPlan = request.session[SESSION_KEY_PLAN]
|
flow_plan: FlowPlan = request.session.get(SESSION_KEY_PLAN, FlowPlan(""))
|
||||||
flow_plan.context[PLAN_CONTEXT_METHOD] = method
|
flow_plan.context[PLAN_CONTEXT_METHOD] = method
|
||||||
flow_plan.context[PLAN_CONTEXT_METHOD_ARGS] = cleanse_dict(sanitize_dict(kwargs))
|
flow_plan.context[PLAN_CONTEXT_METHOD_ARGS] = cleanse_dict(sanitize_dict(kwargs))
|
||||||
request.session[SESSION_KEY_PLAN] = flow_plan
|
request.session[SESSION_KEY_PLAN] = flow_plan
|
||||||
|
|||||||
@ -14,7 +14,7 @@ from django.db import models
|
|||||||
from django.db.models import Q, QuerySet, options
|
from django.db.models import Q, QuerySet, options
|
||||||
from django.http import HttpRequest
|
from django.http import HttpRequest
|
||||||
from django.templatetags.static import static
|
from django.templatetags.static import static
|
||||||
from django.utils.functional import cached_property
|
from django.utils.functional import SimpleLazyObject, cached_property
|
||||||
from django.utils.html import escape
|
from django.utils.html import escape
|
||||||
from django.utils.timezone import now
|
from django.utils.timezone import now
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
@ -284,13 +284,24 @@ class Application(PolicyBindingModel):
|
|||||||
return self.meta_icon.name
|
return self.meta_icon.name
|
||||||
return self.meta_icon.url
|
return self.meta_icon.url
|
||||||
|
|
||||||
def get_launch_url(self) -> Optional[str]:
|
def get_launch_url(self, user: Optional["User"] = None) -> Optional[str]:
|
||||||
"""Get launch URL if set, otherwise attempt to get launch URL based on provider."""
|
"""Get launch URL if set, otherwise attempt to get launch URL based on provider."""
|
||||||
|
url = None
|
||||||
if self.meta_launch_url:
|
if self.meta_launch_url:
|
||||||
return self.meta_launch_url
|
url = self.meta_launch_url
|
||||||
if provider := self.get_provider():
|
if provider := self.get_provider():
|
||||||
return provider.launch_url
|
url = provider.launch_url
|
||||||
return None
|
if user and url:
|
||||||
|
if isinstance(user, SimpleLazyObject):
|
||||||
|
user._setup()
|
||||||
|
user = user._wrapped
|
||||||
|
try:
|
||||||
|
return url % user.__dict__
|
||||||
|
# pylint: disable=broad-except
|
||||||
|
except Exception as exc:
|
||||||
|
LOGGER.warning("Failed to format launch url", exc=exc)
|
||||||
|
return url
|
||||||
|
return url
|
||||||
|
|
||||||
def get_provider(self) -> Optional[Provider]:
|
def get_provider(self) -> Optional[Provider]:
|
||||||
"""Get casted provider instance"""
|
"""Get casted provider instance"""
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
"""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
|
||||||
@ -11,6 +12,8 @@ from django.dispatch import receiver
|
|||||||
from django.http.request import HttpRequest
|
from django.http.request import HttpRequest
|
||||||
from prometheus_client import Gauge
|
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()
|
||||||
|
|
||||||
@ -20,6 +23,17 @@ 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, **_):
|
||||||
@ -27,11 +41,6 @@ def post_save_application(sender: type[Model], instance, created: bool, **_):
|
|||||||
from authentik.core.api.applications import user_app_cache_key
|
from authentik.core.api.applications import user_app_cache_key
|
||||||
from authentik.core.models import Application
|
from authentik.core.models import Application
|
||||||
|
|
||||||
GAUGE_MODELS.labels(
|
|
||||||
model_name=sender._meta.model_name,
|
|
||||||
app=sender._meta.app_label,
|
|
||||||
).set(sender.objects.count())
|
|
||||||
|
|
||||||
if sender != Application:
|
if sender != Application:
|
||||||
return
|
return
|
||||||
if not created: # pragma: no cover
|
if not created: # pragma: no cover
|
||||||
|
|||||||
@ -1,17 +1,7 @@
|
|||||||
"""authentik core tasks"""
|
"""authentik core tasks"""
|
||||||
from datetime import datetime
|
|
||||||
from io import StringIO
|
|
||||||
from os import environ
|
|
||||||
|
|
||||||
from boto3.exceptions import Boto3Error
|
|
||||||
from botocore.exceptions import BotoCoreError, ClientError
|
|
||||||
from dbbackup.db.exceptions import CommandConnectorError
|
|
||||||
from django.contrib.humanize.templatetags.humanize import naturaltime
|
|
||||||
from django.contrib.sessions.backends.cache import KEY_PREFIX
|
from django.contrib.sessions.backends.cache import KEY_PREFIX
|
||||||
from django.core import management
|
|
||||||
from django.core.cache import cache
|
from django.core.cache import cache
|
||||||
from django.utils.timezone import now
|
from django.utils.timezone import now
|
||||||
from kubernetes.config.incluster_config import SERVICE_HOST_ENV_NAME
|
|
||||||
from structlog.stdlib import get_logger
|
from structlog.stdlib import get_logger
|
||||||
|
|
||||||
from authentik.core.models import AuthenticatedSession, ExpiringModel
|
from authentik.core.models import AuthenticatedSession, ExpiringModel
|
||||||
@ -21,7 +11,6 @@ from authentik.events.monitored_tasks import (
|
|||||||
TaskResultStatus,
|
TaskResultStatus,
|
||||||
prefill_task,
|
prefill_task,
|
||||||
)
|
)
|
||||||
from authentik.lib.config import CONFIG
|
|
||||||
from authentik.root.celery import CELERY_APP
|
from authentik.root.celery import CELERY_APP
|
||||||
|
|
||||||
LOGGER = get_logger()
|
LOGGER = get_logger()
|
||||||
@ -53,46 +42,3 @@ def clean_expired_models(self: MonitoredTask):
|
|||||||
LOGGER.debug("Expired sessions", model=AuthenticatedSession, amount=amount)
|
LOGGER.debug("Expired sessions", model=AuthenticatedSession, amount=amount)
|
||||||
messages.append(f"Expired {amount} {AuthenticatedSession._meta.verbose_name_plural}")
|
messages.append(f"Expired {amount} {AuthenticatedSession._meta.verbose_name_plural}")
|
||||||
self.set_status(TaskResult(TaskResultStatus.SUCCESSFUL, messages))
|
self.set_status(TaskResult(TaskResultStatus.SUCCESSFUL, messages))
|
||||||
|
|
||||||
|
|
||||||
def should_backup() -> bool:
|
|
||||||
"""Check if we should be doing backups"""
|
|
||||||
if SERVICE_HOST_ENV_NAME in environ and not CONFIG.y("postgresql.s3_backup.bucket"):
|
|
||||||
LOGGER.info("Running in k8s and s3 backups are not configured, skipping")
|
|
||||||
return False
|
|
||||||
if not CONFIG.y_bool("postgresql.backup.enabled"):
|
|
||||||
return False
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
@CELERY_APP.task(bind=True, base=MonitoredTask)
|
|
||||||
@prefill_task
|
|
||||||
def backup_database(self: MonitoredTask): # pragma: no cover
|
|
||||||
"""Database backup"""
|
|
||||||
self.result_timeout_hours = 25
|
|
||||||
if not should_backup():
|
|
||||||
self.set_status(TaskResult(TaskResultStatus.UNKNOWN, ["Backups are not configured."]))
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
start = datetime.now()
|
|
||||||
out = StringIO()
|
|
||||||
management.call_command("dbbackup", quiet=True, stdout=out)
|
|
||||||
self.set_status(
|
|
||||||
TaskResult(
|
|
||||||
TaskResultStatus.SUCCESSFUL,
|
|
||||||
[
|
|
||||||
f"Successfully finished database backup {naturaltime(start)} {out.getvalue()}",
|
|
||||||
],
|
|
||||||
)
|
|
||||||
)
|
|
||||||
LOGGER.info("Successfully backed up database.")
|
|
||||||
except (
|
|
||||||
IOError,
|
|
||||||
BotoCoreError,
|
|
||||||
ClientError,
|
|
||||||
Boto3Error,
|
|
||||||
PermissionError,
|
|
||||||
CommandConnectorError,
|
|
||||||
ValueError,
|
|
||||||
) as exc:
|
|
||||||
self.set_status(TaskResult(TaskResultStatus.ERROR).with_error(exc))
|
|
||||||
|
|||||||
@ -16,6 +16,7 @@
|
|||||||
{% block head_before %}
|
{% block head_before %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
<link rel="stylesheet" type="text/css" href="{% static 'dist/authentik.css' %}">
|
<link rel="stylesheet" type="text/css" href="{% static 'dist/authentik.css' %}">
|
||||||
|
<link rel="stylesheet" type="text/css" href="{% static 'dist/custom.css' %}">
|
||||||
<script src="{% static 'dist/poly.js' %}" type="module"></script>
|
<script src="{% static 'dist/poly.js' %}" type="module"></script>
|
||||||
{% block head %}
|
{% block head %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@ -10,8 +10,8 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block body %}
|
{% block body %}
|
||||||
<ak-message-container></ak-message-container>
|
<ak-message-container data-refresh-on-locale="true"></ak-message-container>
|
||||||
<ak-interface-admin>
|
<ak-interface-admin data-refresh-on-locale="true">
|
||||||
<section class="ak-static-page pf-c-page__main-section pf-m-no-padding-mobile pf-m-xl">
|
<section class="ak-static-page pf-c-page__main-section pf-m-no-padding-mobile pf-m-xl">
|
||||||
<div class="pf-c-empty-state" style="height: 100vh;">
|
<div class="pf-c-empty-state" style="height: 100vh;">
|
||||||
<div class="pf-c-empty-state__content">
|
<div class="pf-c-empty-state__content">
|
||||||
|
|||||||
@ -20,8 +20,8 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block body %}
|
{% block body %}
|
||||||
<ak-message-container></ak-message-container>
|
<ak-message-container data-refresh-on-locale="true"></ak-message-container>
|
||||||
<ak-flow-executor>
|
<ak-flow-executor data-refresh-on-locale="true">
|
||||||
<section class="ak-static-page pf-c-page__main-section pf-m-no-padding-mobile pf-m-xl">
|
<section class="ak-static-page pf-c-page__main-section pf-m-no-padding-mobile pf-m-xl">
|
||||||
<div class="pf-c-empty-state" style="height: 100vh;">
|
<div class="pf-c-empty-state" style="height: 100vh;">
|
||||||
<div class="pf-c-empty-state__content">
|
<div class="pf-c-empty-state__content">
|
||||||
|
|||||||
@ -10,8 +10,8 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block body %}
|
{% block body %}
|
||||||
<ak-message-container></ak-message-container>
|
<ak-message-container data-refresh-on-locale="true"></ak-message-container>
|
||||||
<ak-interface-user>
|
<ak-interface-user data-refresh-on-locale="true">
|
||||||
<section class="ak-static-page pf-c-page__main-section pf-m-no-padding-mobile pf-m-xl">
|
<section class="ak-static-page pf-c-page__main-section pf-m-no-padding-mobile pf-m-xl">
|
||||||
<div class="pf-c-empty-state" style="height: 100vh;">
|
<div class="pf-c-empty-state" style="height: 100vh;">
|
||||||
<div class="pf-c-empty-state__content">
|
<div class="pf-c-empty-state__content">
|
||||||
|
|||||||
@ -13,7 +13,9 @@ class TestApplicationsAPI(APITestCase):
|
|||||||
|
|
||||||
def setUp(self) -> None:
|
def setUp(self) -> None:
|
||||||
self.user = create_test_admin_user()
|
self.user = create_test_admin_user()
|
||||||
self.allowed = Application.objects.create(name="allowed", slug="allowed")
|
self.allowed = Application.objects.create(
|
||||||
|
name="allowed", slug="allowed", meta_launch_url="https://goauthentik.io/%(username)s"
|
||||||
|
)
|
||||||
self.denied = Application.objects.create(name="denied", slug="denied")
|
self.denied = Application.objects.create(name="denied", slug="denied")
|
||||||
PolicyBinding.objects.create(
|
PolicyBinding.objects.create(
|
||||||
target=self.denied,
|
target=self.denied,
|
||||||
@ -64,8 +66,8 @@ class TestApplicationsAPI(APITestCase):
|
|||||||
"slug": "allowed",
|
"slug": "allowed",
|
||||||
"provider": None,
|
"provider": None,
|
||||||
"provider_obj": None,
|
"provider_obj": None,
|
||||||
"launch_url": None,
|
"launch_url": f"https://goauthentik.io/{self.user.username}",
|
||||||
"meta_launch_url": "",
|
"meta_launch_url": "https://goauthentik.io/%(username)s",
|
||||||
"meta_icon": None,
|
"meta_icon": None,
|
||||||
"meta_description": "",
|
"meta_description": "",
|
||||||
"meta_publisher": "",
|
"meta_publisher": "",
|
||||||
@ -100,8 +102,8 @@ class TestApplicationsAPI(APITestCase):
|
|||||||
"slug": "allowed",
|
"slug": "allowed",
|
||||||
"provider": None,
|
"provider": None,
|
||||||
"provider_obj": None,
|
"provider_obj": None,
|
||||||
"launch_url": None,
|
"launch_url": f"https://goauthentik.io/{self.user.username}",
|
||||||
"meta_launch_url": "",
|
"meta_launch_url": "https://goauthentik.io/%(username)s",
|
||||||
"meta_icon": None,
|
"meta_icon": None,
|
||||||
"meta_description": "",
|
"meta_description": "",
|
||||||
"meta_publisher": "",
|
"meta_publisher": "",
|
||||||
|
|||||||
67
authentik/core/tests/test_applications_views.py
Normal file
67
authentik/core/tests/test_applications_views.py
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
"""Test Applications API"""
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
from django.urls import reverse
|
||||||
|
|
||||||
|
from authentik.core.models import Application
|
||||||
|
from authentik.core.tests.utils import create_test_admin_user, create_test_tenant
|
||||||
|
from authentik.flows.models import Flow, FlowDesignation
|
||||||
|
from authentik.flows.tests import FlowTestCase
|
||||||
|
from authentik.tenants.models import Tenant
|
||||||
|
|
||||||
|
|
||||||
|
class TestApplicationsViews(FlowTestCase):
|
||||||
|
"""Test applications Views"""
|
||||||
|
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.user = create_test_admin_user()
|
||||||
|
self.allowed = Application.objects.create(
|
||||||
|
name="allowed", slug="allowed", meta_launch_url="https://goauthentik.io/%(username)s"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_check_redirect(self):
|
||||||
|
"""Test redirect"""
|
||||||
|
empty_flow = Flow.objects.create(
|
||||||
|
name="foo",
|
||||||
|
slug="foo",
|
||||||
|
designation=FlowDesignation.AUTHENTICATION,
|
||||||
|
)
|
||||||
|
tenant: Tenant = create_test_tenant()
|
||||||
|
tenant.flow_authentication = empty_flow
|
||||||
|
tenant.save()
|
||||||
|
response = self.client.get(
|
||||||
|
reverse(
|
||||||
|
"authentik_core:application-launch",
|
||||||
|
kwargs={"application_slug": self.allowed.slug},
|
||||||
|
),
|
||||||
|
follow=True,
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
with patch(
|
||||||
|
"authentik.flows.stage.StageView.get_pending_user", MagicMock(return_value=self.user)
|
||||||
|
):
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("authentik_api:flow-executor", kwargs={"flow_slug": empty_flow.slug})
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertStageRedirects(response, f"https://goauthentik.io/{self.user.username}")
|
||||||
|
|
||||||
|
def test_check_redirect_auth(self):
|
||||||
|
"""Test redirect"""
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
empty_flow = Flow.objects.create(
|
||||||
|
name="foo",
|
||||||
|
slug="foo",
|
||||||
|
designation=FlowDesignation.AUTHENTICATION,
|
||||||
|
)
|
||||||
|
tenant: Tenant = create_test_tenant()
|
||||||
|
tenant.flow_authentication = empty_flow
|
||||||
|
tenant.save()
|
||||||
|
response = self.client.get(
|
||||||
|
reverse(
|
||||||
|
"authentik_core:application-launch",
|
||||||
|
kwargs={"application_slug": self.allowed.slug},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
self.assertEqual(response.url, f"https://goauthentik.io/{self.user.username}")
|
||||||
@ -30,6 +30,7 @@ class TestTokenAPI(APITestCase):
|
|||||||
self.assertEqual(token.user, self.user)
|
self.assertEqual(token.user, self.user)
|
||||||
self.assertEqual(token.intent, TokenIntents.INTENT_API)
|
self.assertEqual(token.intent, TokenIntents.INTENT_API)
|
||||||
self.assertEqual(token.expiring, True)
|
self.assertEqual(token.expiring, True)
|
||||||
|
self.assertTrue(self.user.has_perm("authentik_core.view_token_key", token))
|
||||||
|
|
||||||
def test_token_create_invalid(self):
|
def test_token_create_invalid(self):
|
||||||
"""Test token creation endpoint (invalid data)"""
|
"""Test token creation endpoint (invalid data)"""
|
||||||
|
|||||||
@ -2,12 +2,7 @@
|
|||||||
from django.urls.base import reverse
|
from django.urls.base import reverse
|
||||||
from rest_framework.test import APITestCase
|
from rest_framework.test import APITestCase
|
||||||
|
|
||||||
from authentik.core.models import (
|
from authentik.core.models import User
|
||||||
USER_ATTRIBUTE_CHANGE_EMAIL,
|
|
||||||
USER_ATTRIBUTE_CHANGE_NAME,
|
|
||||||
USER_ATTRIBUTE_CHANGE_USERNAME,
|
|
||||||
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_key
|
||||||
@ -22,51 +17,6 @@ class TestUsersAPI(APITestCase):
|
|||||||
self.admin = create_test_admin_user()
|
self.admin = create_test_admin_user()
|
||||||
self.user = User.objects.create(username="test-user")
|
self.user = User.objects.create(username="test-user")
|
||||||
|
|
||||||
def test_update_self(self):
|
|
||||||
"""Test update_self"""
|
|
||||||
self.admin.attributes["foo"] = "bar"
|
|
||||||
self.admin.save()
|
|
||||||
self.admin.refresh_from_db()
|
|
||||||
self.client.force_login(self.admin)
|
|
||||||
response = self.client.put(
|
|
||||||
reverse("authentik_api:user-update-self"), data={"username": "foo", "name": "foo"}
|
|
||||||
)
|
|
||||||
self.admin.refresh_from_db()
|
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
self.assertEqual(self.admin.attributes["foo"], "bar")
|
|
||||||
self.assertEqual(self.admin.username, "foo")
|
|
||||||
self.assertEqual(self.admin.name, "foo")
|
|
||||||
|
|
||||||
def test_update_self_name_denied(self):
|
|
||||||
"""Test update_self"""
|
|
||||||
self.admin.attributes[USER_ATTRIBUTE_CHANGE_NAME] = False
|
|
||||||
self.admin.save()
|
|
||||||
self.client.force_login(self.admin)
|
|
||||||
response = self.client.put(
|
|
||||||
reverse("authentik_api:user-update-self"), data={"username": "foo", "name": "foo"}
|
|
||||||
)
|
|
||||||
self.assertEqual(response.status_code, 400)
|
|
||||||
|
|
||||||
def test_update_self_username_denied(self):
|
|
||||||
"""Test update_self"""
|
|
||||||
self.admin.attributes[USER_ATTRIBUTE_CHANGE_USERNAME] = False
|
|
||||||
self.admin.save()
|
|
||||||
self.client.force_login(self.admin)
|
|
||||||
response = self.client.put(
|
|
||||||
reverse("authentik_api:user-update-self"), data={"username": "foo", "name": "foo"}
|
|
||||||
)
|
|
||||||
self.assertEqual(response.status_code, 400)
|
|
||||||
|
|
||||||
def test_update_self_email_denied(self):
|
|
||||||
"""Test update_self"""
|
|
||||||
self.admin.attributes[USER_ATTRIBUTE_CHANGE_EMAIL] = False
|
|
||||||
self.admin.save()
|
|
||||||
self.client.force_login(self.admin)
|
|
||||||
response = self.client.put(
|
|
||||||
reverse("authentik_api:user-update-self"), data={"email": "foo", "name": "foo"}
|
|
||||||
)
|
|
||||||
self.assertEqual(response.status_code, 400)
|
|
||||||
|
|
||||||
def test_metrics(self):
|
def test_metrics(self):
|
||||||
"""Test user's metrics"""
|
"""Test user's metrics"""
|
||||||
self.client.force_login(self.admin)
|
self.client.force_login(self.admin)
|
||||||
|
|||||||
@ -29,4 +29,4 @@ class UserSettingSerializer(PassiveSerializer):
|
|||||||
component = CharField()
|
component = CharField()
|
||||||
title = CharField()
|
title = CharField()
|
||||||
configure_url = CharField(required=False)
|
configure_url = CharField(required=False)
|
||||||
icon_url = CharField()
|
icon_url = CharField(required=False)
|
||||||
|
|||||||
@ -5,7 +5,7 @@ from django.views.decorators.csrf import ensure_csrf_cookie
|
|||||||
from django.views.generic import RedirectView
|
from django.views.generic import RedirectView
|
||||||
from django.views.generic.base import TemplateView
|
from django.views.generic.base import TemplateView
|
||||||
|
|
||||||
from authentik.core.views import impersonate
|
from authentik.core.views import apps, impersonate
|
||||||
from authentik.core.views.interface import FlowInterfaceView
|
from authentik.core.views.interface import FlowInterfaceView
|
||||||
from authentik.core.views.session import EndSessionView
|
from authentik.core.views.session import EndSessionView
|
||||||
|
|
||||||
@ -15,6 +15,12 @@ urlpatterns = [
|
|||||||
login_required(RedirectView.as_view(pattern_name="authentik_core:if-user")),
|
login_required(RedirectView.as_view(pattern_name="authentik_core:if-user")),
|
||||||
name="root-redirect",
|
name="root-redirect",
|
||||||
),
|
),
|
||||||
|
path(
|
||||||
|
# We have to use this format since everything else uses applications/o or applications/saml
|
||||||
|
"application/launch/<slug:application_slug>/",
|
||||||
|
apps.RedirectToAppLaunch.as_view(),
|
||||||
|
name="application-launch",
|
||||||
|
),
|
||||||
# Impersonation
|
# Impersonation
|
||||||
path(
|
path(
|
||||||
"-/impersonation/<int:user_id>/",
|
"-/impersonation/<int:user_id>/",
|
||||||
|
|||||||
75
authentik/core/views/apps.py
Normal file
75
authentik/core/views/apps.py
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
"""app views"""
|
||||||
|
from django.http import Http404, HttpRequest, HttpResponse, HttpResponseRedirect
|
||||||
|
from django.shortcuts import get_object_or_404
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
from django.views import View
|
||||||
|
|
||||||
|
from authentik.core.models import Application
|
||||||
|
from authentik.flows.challenge import (
|
||||||
|
ChallengeResponse,
|
||||||
|
ChallengeTypes,
|
||||||
|
HttpChallengeResponse,
|
||||||
|
RedirectChallenge,
|
||||||
|
)
|
||||||
|
from authentik.flows.models import in_memory_stage
|
||||||
|
from authentik.flows.planner import PLAN_CONTEXT_APPLICATION, FlowPlanner
|
||||||
|
from authentik.flows.stage import ChallengeStageView
|
||||||
|
from authentik.flows.views.executor import SESSION_KEY_PLAN
|
||||||
|
from authentik.lib.utils.urls import redirect_with_qs
|
||||||
|
from authentik.stages.consent.stage import (
|
||||||
|
PLAN_CONTEXT_CONSENT_HEADER,
|
||||||
|
PLAN_CONTEXT_CONSENT_PERMISSIONS,
|
||||||
|
)
|
||||||
|
from authentik.tenants.models import Tenant
|
||||||
|
|
||||||
|
|
||||||
|
class RedirectToAppLaunch(View):
|
||||||
|
"""Application launch view, redirect to the launch URL"""
|
||||||
|
|
||||||
|
def dispatch(self, request: HttpRequest, application_slug: str) -> HttpResponse:
|
||||||
|
app = get_object_or_404(Application, slug=application_slug)
|
||||||
|
# Check here if the application has any launch URL set, if not 404
|
||||||
|
launch = app.get_launch_url()
|
||||||
|
if not launch:
|
||||||
|
raise Http404
|
||||||
|
# Check if we're authenticated already, saves us the flow run
|
||||||
|
if request.user.is_authenticated:
|
||||||
|
return HttpResponseRedirect(app.get_launch_url(request.user))
|
||||||
|
# otherwise, do a custom flow plan that includes the application that's
|
||||||
|
# being accessed, to improve usability
|
||||||
|
tenant: Tenant = request.tenant
|
||||||
|
flow = tenant.flow_authentication
|
||||||
|
planner = FlowPlanner(flow)
|
||||||
|
planner.allow_empty_flows = True
|
||||||
|
plan = planner.plan(
|
||||||
|
request,
|
||||||
|
{
|
||||||
|
PLAN_CONTEXT_APPLICATION: app,
|
||||||
|
PLAN_CONTEXT_CONSENT_HEADER: _("You're about to sign into %(application)s.")
|
||||||
|
% {"application": app.name},
|
||||||
|
PLAN_CONTEXT_CONSENT_PERMISSIONS: [],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
plan.insert_stage(in_memory_stage(RedirectToAppStage))
|
||||||
|
request.session[SESSION_KEY_PLAN] = plan
|
||||||
|
return redirect_with_qs("authentik_core:if-flow", request.GET, flow_slug=flow.slug)
|
||||||
|
|
||||||
|
|
||||||
|
class RedirectToAppStage(ChallengeStageView):
|
||||||
|
"""Final stage to be inserted after the user logs in"""
|
||||||
|
|
||||||
|
def get_challenge(self, *args, **kwargs) -> RedirectChallenge:
|
||||||
|
app = self.executor.plan.context[PLAN_CONTEXT_APPLICATION]
|
||||||
|
launch = app.get_launch_url(self.get_pending_user())
|
||||||
|
# sanity check to ensure launch is still set
|
||||||
|
if not launch:
|
||||||
|
raise Http404
|
||||||
|
return RedirectChallenge(
|
||||||
|
instance={
|
||||||
|
"type": ChallengeTypes.REDIRECT.value,
|
||||||
|
"to": launch,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
def challenge_valid(self, response: ChallengeResponse) -> HttpResponse:
|
||||||
|
return HttpChallengeResponse(self.get_challenge())
|
||||||
@ -17,6 +17,7 @@ from rest_framework.request import Request
|
|||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
from rest_framework.serializers import ModelSerializer, ValidationError
|
from rest_framework.serializers import ModelSerializer, ValidationError
|
||||||
from rest_framework.viewsets import ModelViewSet
|
from rest_framework.viewsets import ModelViewSet
|
||||||
|
from structlog.stdlib import get_logger
|
||||||
|
|
||||||
from authentik.api.decorators import permission_required
|
from authentik.api.decorators import permission_required
|
||||||
from authentik.core.api.used_by import UsedByMixin
|
from authentik.core.api.used_by import UsedByMixin
|
||||||
@ -26,6 +27,8 @@ from authentik.crypto.managed import MANAGED_KEY
|
|||||||
from authentik.crypto.models import CertificateKeyPair
|
from authentik.crypto.models import CertificateKeyPair
|
||||||
from authentik.events.models import Event, EventAction
|
from authentik.events.models import Event, EventAction
|
||||||
|
|
||||||
|
LOGGER = get_logger()
|
||||||
|
|
||||||
|
|
||||||
class CertificateKeyPairSerializer(ModelSerializer):
|
class CertificateKeyPairSerializer(ModelSerializer):
|
||||||
"""CertificateKeyPair Serializer"""
|
"""CertificateKeyPair Serializer"""
|
||||||
@ -76,8 +79,11 @@ class CertificateKeyPairSerializer(ModelSerializer):
|
|||||||
def validate_certificate_data(self, value: str) -> str:
|
def validate_certificate_data(self, value: str) -> str:
|
||||||
"""Verify that input is a valid PEM x509 Certificate"""
|
"""Verify that input is a valid PEM x509 Certificate"""
|
||||||
try:
|
try:
|
||||||
load_pem_x509_certificate(value.encode("utf-8"), default_backend())
|
# Cast to string to fully load and parse certificate
|
||||||
except ValueError:
|
# Prevents issues like https://github.com/goauthentik/authentik/issues/2082
|
||||||
|
str(load_pem_x509_certificate(value.encode("utf-8"), default_backend()))
|
||||||
|
except ValueError as exc:
|
||||||
|
LOGGER.warning("Failed to load certificate", exc=exc)
|
||||||
raise ValidationError("Unable to load certificate.")
|
raise ValidationError("Unable to load certificate.")
|
||||||
return value
|
return value
|
||||||
|
|
||||||
@ -86,12 +92,17 @@ class CertificateKeyPairSerializer(ModelSerializer):
|
|||||||
# Since this field is optional, data can be empty.
|
# Since this field is optional, data can be empty.
|
||||||
if value != "":
|
if value != "":
|
||||||
try:
|
try:
|
||||||
|
# Cast to string to fully load and parse certificate
|
||||||
|
# Prevents issues like https://github.com/goauthentik/authentik/issues/2082
|
||||||
|
str(
|
||||||
load_pem_private_key(
|
load_pem_private_key(
|
||||||
str.encode("\n".join([x.strip() for x in value.split("\n")])),
|
str.encode("\n".join([x.strip() for x in value.split("\n")])),
|
||||||
password=None,
|
password=None,
|
||||||
backend=default_backend(),
|
backend=default_backend(),
|
||||||
)
|
)
|
||||||
except (ValueError, TypeError):
|
)
|
||||||
|
except (ValueError, TypeError) as exc:
|
||||||
|
LOGGER.warning("Failed to load private key", exc=exc)
|
||||||
raise ValidationError("Unable to load private key (possibly encrypted?).")
|
raise ValidationError("Unable to load private key (possibly encrypted?).")
|
||||||
return value
|
return value
|
||||||
|
|
||||||
|
|||||||
@ -1,7 +1,5 @@
|
|||||||
"""events GeoIP Reader"""
|
"""events GeoIP Reader"""
|
||||||
from datetime import datetime
|
|
||||||
from os import stat
|
from os import stat
|
||||||
from time import time
|
|
||||||
from typing import Optional, TypedDict
|
from typing import Optional, TypedDict
|
||||||
|
|
||||||
from geoip2.database import Reader
|
from geoip2.database import Reader
|
||||||
@ -46,14 +44,18 @@ class GeoIPReader:
|
|||||||
LOGGER.warning("Failed to load GeoIP database", exc=exc)
|
LOGGER.warning("Failed to load GeoIP database", exc=exc)
|
||||||
|
|
||||||
def __check_expired(self):
|
def __check_expired(self):
|
||||||
"""Check if the geoip database has been opened longer than 8 hours,
|
"""Check if the modification date of the GeoIP database has
|
||||||
and re-open it, as it will probably will have been re-downloaded"""
|
changed, and reload it if so"""
|
||||||
now = time()
|
path = CONFIG.y("geoip")
|
||||||
diff = datetime.fromtimestamp(now) - datetime.fromtimestamp(self.__last_mtime)
|
try:
|
||||||
diff_hours = diff.total_seconds() // 3600
|
mtime = stat(path).st_mtime
|
||||||
if diff_hours >= 8:
|
diff = self.__last_mtime < mtime
|
||||||
LOGGER.info("GeoIP databased loaded too long, re-opening", diff=diff)
|
if diff > 0:
|
||||||
|
LOGGER.info("Found new GeoIP Database, reopening", diff=diff)
|
||||||
self.__open()
|
self.__open()
|
||||||
|
except OSError as exc:
|
||||||
|
LOGGER.warning("Failed to check GeoIP age", exc=exc)
|
||||||
|
return
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def enabled(self) -> bool:
|
def enabled(self) -> bool:
|
||||||
|
|||||||
@ -4,7 +4,7 @@ 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 Histogram
|
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
|
||||||
@ -13,10 +13,9 @@ from authentik.core.models import User
|
|||||||
from authentik.events.models import cleanse_dict
|
from authentik.events.models import cleanse_dict
|
||||||
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, FlowStageBinding, Stage
|
from authentik.flows.models import Flow, FlowDesignation, FlowStageBinding, 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
|
||||||
from authentik.root.monitoring import UpdatingGauge
|
|
||||||
|
|
||||||
LOGGER = get_logger()
|
LOGGER = get_logger()
|
||||||
PLAN_CONTEXT_PENDING_USER = "pending_user"
|
PLAN_CONTEXT_PENDING_USER = "pending_user"
|
||||||
@ -27,10 +26,9 @@ 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 = UpdatingGauge(
|
GAUGE_FLOWS_CACHED = Gauge(
|
||||||
"authentik_flows_cached",
|
"authentik_flows_cached",
|
||||||
"Cached flows",
|
"Cached flows",
|
||||||
update_func=lambda: len(cache.keys("flow_*") or []),
|
|
||||||
)
|
)
|
||||||
HIST_FLOWS_PLAN_TIME = Histogram(
|
HIST_FLOWS_PLAN_TIME = Histogram(
|
||||||
"authentik_flows_plan_time",
|
"authentik_flows_plan_time",
|
||||||
@ -158,6 +156,7 @@ class FlowPlanner:
|
|||||||
# User is passing so far, check if we have a cached plan
|
# User is passing so far, check if we have a cached plan
|
||||||
cached_plan_key = cache_key(self.flow, user)
|
cached_plan_key = cache_key(self.flow, user)
|
||||||
cached_plan = cache.get(cached_plan_key, None)
|
cached_plan = cache.get(cached_plan_key, None)
|
||||||
|
if self.flow.designation not in [FlowDesignation.STAGE_CONFIGURATION]:
|
||||||
if cached_plan and self.use_cache:
|
if cached_plan and self.use_cache:
|
||||||
self._logger.debug(
|
self._logger.debug(
|
||||||
"f(plan): taking plan from cache",
|
"f(plan): taking plan from cache",
|
||||||
@ -171,7 +170,6 @@ class FlowPlanner:
|
|||||||
)
|
)
|
||||||
plan = self._build_plan(user, request, default_context)
|
plan = self._build_plan(user, request, default_context)
|
||||||
cache.set(cache_key(self.flow, user), plan, CACHE_TIMEOUT)
|
cache.set(cache_key(self.flow, user), plan, CACHE_TIMEOUT)
|
||||||
GAUGE_FLOWS_CACHED.update()
|
|
||||||
if not plan.bindings and not self.allow_empty_flows:
|
if not plan.bindings and not self.allow_empty_flows:
|
||||||
raise EmptyFlowException()
|
raise EmptyFlowException()
|
||||||
return plan
|
return plan
|
||||||
|
|||||||
@ -4,6 +4,9 @@ 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.root.monitoring import monitoring_set
|
||||||
|
|
||||||
LOGGER = get_logger()
|
LOGGER = get_logger()
|
||||||
|
|
||||||
|
|
||||||
@ -14,6 +17,13 @@ def delete_cache_prefix(prefix: str) -> int:
|
|||||||
return len(keys)
|
return len(keys)
|
||||||
|
|
||||||
|
|
||||||
|
@receiver(monitoring_set)
|
||||||
|
# pylint: disable=unused-argument
|
||||||
|
def monitoring_set_flows(sender, **kwargs):
|
||||||
|
"""set flow gauges"""
|
||||||
|
GAUGE_FLOWS_CACHED.set(len(cache.keys("flow_*") or []))
|
||||||
|
|
||||||
|
|
||||||
@receiver(post_save)
|
@receiver(post_save)
|
||||||
@receiver(pre_delete)
|
@receiver(pre_delete)
|
||||||
# pylint: disable=unused-argument
|
# pylint: disable=unused-argument
|
||||||
|
|||||||
@ -118,9 +118,12 @@ class ChallengeStageView(StageView):
|
|||||||
"""Allow usage of placeholder in flow title."""
|
"""Allow usage of placeholder in flow title."""
|
||||||
if not self.executor.plan:
|
if not self.executor.plan:
|
||||||
return self.executor.flow.title
|
return self.executor.flow.title
|
||||||
|
try:
|
||||||
return self.executor.flow.title % {
|
return self.executor.flow.title % {
|
||||||
"app": self.executor.plan.context.get(PLAN_CONTEXT_APPLICATION, "")
|
"app": self.executor.plan.context.get(PLAN_CONTEXT_APPLICATION, "")
|
||||||
}
|
}
|
||||||
|
except ValueError:
|
||||||
|
return self.executor.flow.title
|
||||||
|
|
||||||
def _get_challenge(self, *args, **kwargs) -> Challenge:
|
def _get_challenge(self, *args, **kwargs) -> Challenge:
|
||||||
with Hub.current.start_span(
|
with Hub.current.start_span(
|
||||||
|
|||||||
@ -5,16 +5,6 @@ postgresql:
|
|||||||
user: authentik
|
user: authentik
|
||||||
port: 5432
|
port: 5432
|
||||||
password: 'env://POSTGRES_PASSWORD'
|
password: 'env://POSTGRES_PASSWORD'
|
||||||
backup:
|
|
||||||
enabled: true
|
|
||||||
s3_backup:
|
|
||||||
access_key: ""
|
|
||||||
secret_key: ""
|
|
||||||
bucket: ""
|
|
||||||
region: eu-central-1
|
|
||||||
host: ""
|
|
||||||
location: ""
|
|
||||||
insecure_skip_verify: false
|
|
||||||
|
|
||||||
web:
|
web:
|
||||||
listen: 0.0.0.0:9000
|
listen: 0.0.0.0:9000
|
||||||
@ -46,7 +36,7 @@ error_reporting:
|
|||||||
enabled: false
|
enabled: false
|
||||||
environment: customer
|
environment: customer
|
||||||
send_pii: false
|
send_pii: false
|
||||||
sample_rate: 0.5
|
sample_rate: 0.3
|
||||||
|
|
||||||
# Global email settings
|
# Global email settings
|
||||||
email:
|
email:
|
||||||
@ -65,18 +55,15 @@ outposts:
|
|||||||
# %(version)s: Current version; 2021.4.1
|
# %(version)s: Current version; 2021.4.1
|
||||||
# %(build_hash)s: Build hash if you're running a beta version
|
# %(build_hash)s: Build hash if you're running a beta version
|
||||||
container_image_base: ghcr.io/goauthentik/%(type)s:%(version)s
|
container_image_base: ghcr.io/goauthentik/%(type)s:%(version)s
|
||||||
|
discover: true
|
||||||
|
|
||||||
cookie_domain: null
|
cookie_domain: null
|
||||||
disable_update_check: false
|
disable_update_check: false
|
||||||
disable_startup_analytics: false
|
disable_startup_analytics: false
|
||||||
avatars: env://AUTHENTIK_AUTHENTIK__AVATARS?gravatar
|
avatars: env://AUTHENTIK_AUTHENTIK__AVATARS?gravatar
|
||||||
geoip: "./GeoLite2-City.mmdb"
|
geoip: "/geoip/GeoLite2-City.mmdb"
|
||||||
|
|
||||||
footer_links:
|
footer_links: []
|
||||||
- name: Documentation
|
|
||||||
href: https://goauthentik.io/docs/?utm_source=authentik
|
|
||||||
- name: authentik Website
|
|
||||||
href: https://goauthentik.io/?utm_source=authentik
|
|
||||||
|
|
||||||
default_user_change_name: true
|
default_user_change_name: true
|
||||||
default_user_change_email: true
|
default_user_change_email: true
|
||||||
|
|||||||
@ -32,6 +32,7 @@ class BaseEvaluator:
|
|||||||
self._globals = {
|
self._globals = {
|
||||||
"regex_match": BaseEvaluator.expr_regex_match,
|
"regex_match": BaseEvaluator.expr_regex_match,
|
||||||
"regex_replace": BaseEvaluator.expr_regex_replace,
|
"regex_replace": BaseEvaluator.expr_regex_replace,
|
||||||
|
"list_flatten": BaseEvaluator.expr_flatten,
|
||||||
"ak_is_group_member": BaseEvaluator.expr_is_group_member,
|
"ak_is_group_member": BaseEvaluator.expr_is_group_member,
|
||||||
"ak_user_by": BaseEvaluator.expr_user_by,
|
"ak_user_by": BaseEvaluator.expr_user_by,
|
||||||
"ak_logger": get_logger(),
|
"ak_logger": get_logger(),
|
||||||
@ -40,6 +41,15 @@ class BaseEvaluator:
|
|||||||
self._context = {}
|
self._context = {}
|
||||||
self._filename = "BaseEvalautor"
|
self._filename = "BaseEvalautor"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def expr_flatten(value: list[Any] | Any) -> Optional[Any]:
|
||||||
|
"""Flatten `value` if its a list"""
|
||||||
|
if isinstance(value, list):
|
||||||
|
if len(value) < 1:
|
||||||
|
return None
|
||||||
|
return value[0]
|
||||||
|
return value
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def expr_regex_match(value: Any, regex: str) -> bool:
|
def expr_regex_match(value: Any, regex: str) -> bool:
|
||||||
"""Expression Filter to run re.search"""
|
"""Expression Filter to run re.search"""
|
||||||
|
|||||||
6
authentik/lib/merge.py
Normal file
6
authentik/lib/merge.py
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
"""merge utils"""
|
||||||
|
from deepmerge import Merger
|
||||||
|
|
||||||
|
MERGE_LIST_UNIQUE = Merger(
|
||||||
|
[(list, ["append_unique"]), (dict, ["merge"]), (set, ["union"])], ["override"], ["override"]
|
||||||
|
)
|
||||||
@ -3,8 +3,6 @@ from typing import 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
|
||||||
from botocore.client import ClientError
|
|
||||||
from botocore.exceptions import BotoCoreError
|
|
||||||
from celery.exceptions import CeleryError
|
from celery.exceptions import CeleryError
|
||||||
from channels.middleware import BaseMiddleware
|
from channels.middleware import BaseMiddleware
|
||||||
from channels_redis.core import ChannelFull
|
from channels_redis.core import ChannelFull
|
||||||
@ -81,9 +79,6 @@ def before_send(event: dict, hint: dict) -> Optional[dict]:
|
|||||||
WorkerLostError,
|
WorkerLostError,
|
||||||
CeleryError,
|
CeleryError,
|
||||||
SoftTimeLimitExceeded,
|
SoftTimeLimitExceeded,
|
||||||
# S3 errors
|
|
||||||
BotoCoreError,
|
|
||||||
ClientError,
|
|
||||||
# custom baseclass
|
# custom baseclass
|
||||||
SentryIgnoredException,
|
SentryIgnoredException,
|
||||||
# ldap errors
|
# ldap errors
|
||||||
@ -101,8 +96,6 @@ def before_send(event: dict, hint: dict) -> Optional[dict]:
|
|||||||
return None
|
return None
|
||||||
if "logger" in event:
|
if "logger" in event:
|
||||||
if event["logger"] in [
|
if event["logger"] in [
|
||||||
"dbbackup",
|
|
||||||
"botocore",
|
|
||||||
"kombu",
|
"kombu",
|
||||||
"asyncio",
|
"asyncio",
|
||||||
"multiprocessing",
|
"multiprocessing",
|
||||||
@ -111,6 +104,7 @@ def before_send(event: dict, hint: dict) -> Optional[dict]:
|
|||||||
"django_redis.cache",
|
"django_redis.cache",
|
||||||
"celery.backends.redis",
|
"celery.backends.redis",
|
||||||
"celery.worker",
|
"celery.worker",
|
||||||
|
"paramiko.transport",
|
||||||
]:
|
]:
|
||||||
return None
|
return None
|
||||||
LOGGER.debug("sending event to sentry", exc=exc_value, source_logger=event.get("logger", None))
|
LOGGER.debug("sending event to sentry", exc=exc_value, source_logger=event.get("logger", None))
|
||||||
|
|||||||
@ -1,5 +1,4 @@
|
|||||||
"""http helpers"""
|
"""http helpers"""
|
||||||
from os import environ
|
|
||||||
from typing import Any, Optional
|
from typing import Any, Optional
|
||||||
|
|
||||||
from django.http import HttpRequest
|
from django.http import HttpRequest
|
||||||
@ -7,7 +6,7 @@ from requests.sessions import Session
|
|||||||
from sentry_sdk.hub import Hub
|
from sentry_sdk.hub import Hub
|
||||||
from structlog.stdlib import get_logger
|
from structlog.stdlib import get_logger
|
||||||
|
|
||||||
from authentik import ENV_GIT_HASH_KEY, __version__
|
from authentik import get_full_version
|
||||||
|
|
||||||
OUTPOST_REMOTE_IP_HEADER = "HTTP_X_AUTHENTIK_REMOTE_IP"
|
OUTPOST_REMOTE_IP_HEADER = "HTTP_X_AUTHENTIK_REMOTE_IP"
|
||||||
OUTPOST_TOKEN_HEADER = "HTTP_X_AUTHENTIK_OUTPOST_TOKEN" # nosec
|
OUTPOST_TOKEN_HEADER = "HTTP_X_AUTHENTIK_OUTPOST_TOKEN" # nosec
|
||||||
@ -75,8 +74,7 @@ def get_client_ip(request: Optional[HttpRequest]) -> str:
|
|||||||
|
|
||||||
def authentik_user_agent() -> str:
|
def authentik_user_agent() -> str:
|
||||||
"""Get a common user agent"""
|
"""Get a common user agent"""
|
||||||
build = environ.get(ENV_GIT_HASH_KEY, "tagged")
|
return f"authentik@{get_full_version()}"
|
||||||
return f"authentik@{__version__} (build={build})"
|
|
||||||
|
|
||||||
|
|
||||||
def get_http_session() -> Session:
|
def get_http_session() -> Session:
|
||||||
|
|||||||
@ -58,4 +58,6 @@ def get_env() -> str:
|
|||||||
return "compose"
|
return "compose"
|
||||||
if CONFIG.y_bool("debug"):
|
if CONFIG.y_bool("debug"):
|
||||||
return "dev"
|
return "dev"
|
||||||
|
if "AK_APPLIANCE" in os.environ:
|
||||||
|
return os.environ["AK_APPLIANCE"]
|
||||||
return "custom"
|
return "custom"
|
||||||
|
|||||||
@ -1,6 +1,4 @@
|
|||||||
"""Outpost API Views"""
|
"""Outpost API Views"""
|
||||||
from os import environ
|
|
||||||
|
|
||||||
from dacite.core import from_dict
|
from dacite.core import from_dict
|
||||||
from dacite.exceptions import DaciteError
|
from dacite.exceptions import DaciteError
|
||||||
from django_filters.filters import ModelMultipleChoiceFilter
|
from django_filters.filters import ModelMultipleChoiceFilter
|
||||||
@ -14,7 +12,7 @@ from rest_framework.response import Response
|
|||||||
from rest_framework.serializers import JSONField, ModelSerializer, ValidationError
|
from rest_framework.serializers import JSONField, ModelSerializer, ValidationError
|
||||||
from rest_framework.viewsets import ModelViewSet
|
from rest_framework.viewsets import ModelViewSet
|
||||||
|
|
||||||
from authentik import ENV_GIT_HASH_KEY
|
from authentik import get_build_hash
|
||||||
from authentik.core.api.providers import ProviderSerializer
|
from authentik.core.api.providers import ProviderSerializer
|
||||||
from authentik.core.api.used_by import UsedByMixin
|
from authentik.core.api.used_by import UsedByMixin
|
||||||
from authentik.core.api.utils import PassiveSerializer, is_dict
|
from authentik.core.api.utils import PassiveSerializer, is_dict
|
||||||
@ -154,7 +152,7 @@ class OutpostViewSet(UsedByMixin, ModelViewSet):
|
|||||||
"version_should": state.version_should,
|
"version_should": state.version_should,
|
||||||
"version_outdated": state.version_outdated,
|
"version_outdated": state.version_outdated,
|
||||||
"build_hash": state.build_hash,
|
"build_hash": state.build_hash,
|
||||||
"build_hash_should": environ.get(ENV_GIT_HASH_KEY, ""),
|
"build_hash_should": get_build_hash(),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
return Response(OutpostHealthSerializer(states, many=True).data)
|
return Response(OutpostHealthSerializer(states, many=True).data)
|
||||||
|
|||||||
@ -55,6 +55,10 @@ class OutpostConsumer(AuthJsonConsumer):
|
|||||||
|
|
||||||
first_msg = False
|
first_msg = False
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
self.logger = get_logger()
|
||||||
|
|
||||||
def connect(self):
|
def connect(self):
|
||||||
super().connect()
|
super().connect()
|
||||||
uuid = self.scope["url_route"]["kwargs"]["pk"]
|
uuid = self.scope["url_route"]["kwargs"]["pk"]
|
||||||
@ -65,7 +69,7 @@ class OutpostConsumer(AuthJsonConsumer):
|
|||||||
)
|
)
|
||||||
if not outpost:
|
if not outpost:
|
||||||
raise DenyConnection()
|
raise DenyConnection()
|
||||||
self.logger = get_logger().bind(outpost=outpost)
|
self.logger = self.logger.bind(outpost=outpost)
|
||||||
try:
|
try:
|
||||||
self.accept()
|
self.accept()
|
||||||
except RuntimeError as exc:
|
except RuntimeError as exc:
|
||||||
|
|||||||
@ -1,12 +1,11 @@
|
|||||||
"""Base Controller"""
|
"""Base Controller"""
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from os import environ
|
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from structlog.stdlib import get_logger
|
from structlog.stdlib import get_logger
|
||||||
from structlog.testing import capture_logs
|
from structlog.testing import capture_logs
|
||||||
|
|
||||||
from authentik import ENV_GIT_HASH_KEY, __version__
|
from authentik import __version__, get_build_hash
|
||||||
from authentik.lib.config import CONFIG
|
from authentik.lib.config import CONFIG
|
||||||
from authentik.lib.sentry import SentryIgnoredException
|
from authentik.lib.sentry import SentryIgnoredException
|
||||||
from authentik.outposts.models import (
|
from authentik.outposts.models import (
|
||||||
@ -102,5 +101,5 @@ class BaseController:
|
|||||||
return image_name_template % {
|
return image_name_template % {
|
||||||
"type": self.outpost.type,
|
"type": self.outpost.type,
|
||||||
"version": __version__,
|
"version": __version__,
|
||||||
"build_hash": environ.get(ENV_GIT_HASH_KEY, ""),
|
"build_hash": get_build_hash(),
|
||||||
}
|
}
|
||||||
|
|||||||
@ -9,6 +9,7 @@ from docker import DockerClient as UpstreamDockerClient
|
|||||||
from docker.errors import DockerException, NotFound
|
from docker.errors import DockerException, NotFound
|
||||||
from docker.models.containers import Container
|
from docker.models.containers import Container
|
||||||
from docker.utils.utils import kwargs_from_env
|
from docker.utils.utils import kwargs_from_env
|
||||||
|
from paramiko.ssh_exception import SSHException
|
||||||
from structlog.stdlib import get_logger
|
from structlog.stdlib import get_logger
|
||||||
from yaml import safe_dump
|
from yaml import safe_dump
|
||||||
|
|
||||||
@ -49,10 +50,13 @@ class DockerClient(UpstreamDockerClient, BaseClient):
|
|||||||
authentication_kp=connection.tls_authentication,
|
authentication_kp=connection.tls_authentication,
|
||||||
)
|
)
|
||||||
tls_config = self.tls.write()
|
tls_config = self.tls.write()
|
||||||
|
try:
|
||||||
super().__init__(
|
super().__init__(
|
||||||
base_url=connection.url,
|
base_url=connection.url,
|
||||||
tls=tls_config,
|
tls=tls_config,
|
||||||
)
|
)
|
||||||
|
except SSHException as exc:
|
||||||
|
raise ServiceConnectionInvalid from exc
|
||||||
self.logger = get_logger()
|
self.logger = get_logger()
|
||||||
# Ensure the client actually works
|
# Ensure the client actually works
|
||||||
self.containers.list()
|
self.containers.list()
|
||||||
@ -102,9 +106,12 @@ class DockerController(BaseController):
|
|||||||
).lower()
|
).lower()
|
||||||
|
|
||||||
def _get_labels(self) -> dict[str, str]:
|
def _get_labels(self) -> dict[str, str]:
|
||||||
return {
|
labels = {
|
||||||
"io.goauthentik.outpost-uuid": self.outpost.pk.hex,
|
"io.goauthentik.outpost-uuid": self.outpost.pk.hex,
|
||||||
}
|
}
|
||||||
|
if self.outpost.config.docker_labels:
|
||||||
|
labels.update(self.outpost.config.docker_labels)
|
||||||
|
return labels
|
||||||
|
|
||||||
def _get_env(self) -> dict[str, str]:
|
def _get_env(self) -> dict[str, str]:
|
||||||
return {
|
return {
|
||||||
|
|||||||
@ -18,6 +18,7 @@ from kubernetes.client import (
|
|||||||
V1SecretKeySelector,
|
V1SecretKeySelector,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
from authentik import __version__, get_full_version
|
||||||
from authentik.outposts.controllers.base import FIELD_MANAGER
|
from authentik.outposts.controllers.base import FIELD_MANAGER
|
||||||
from authentik.outposts.controllers.k8s.base import KubernetesObjectReconciler
|
from authentik.outposts.controllers.k8s.base import KubernetesObjectReconciler
|
||||||
from authentik.outposts.controllers.k8s.triggers import NeedsUpdate
|
from authentik.outposts.controllers.k8s.triggers import NeedsUpdate
|
||||||
@ -52,15 +53,18 @@ class DeploymentReconciler(KubernetesObjectReconciler[V1Deployment]):
|
|||||||
raise NeedsUpdate()
|
raise NeedsUpdate()
|
||||||
super().reconcile(current, reference)
|
super().reconcile(current, reference)
|
||||||
|
|
||||||
def get_pod_meta(self) -> dict[str, str]:
|
def get_pod_meta(self, **kwargs) -> dict[str, str]:
|
||||||
"""Get common object metadata"""
|
"""Get common object metadata"""
|
||||||
return {
|
kwargs.update(
|
||||||
"app.kubernetes.io/name": "authentik-outpost",
|
{
|
||||||
|
"app.kubernetes.io/name": f"authentik-outpost-{self.outpost.type}",
|
||||||
"app.kubernetes.io/managed-by": "goauthentik.io",
|
"app.kubernetes.io/managed-by": "goauthentik.io",
|
||||||
"goauthentik.io/outpost-uuid": self.controller.outpost.uuid.hex,
|
"goauthentik.io/outpost-uuid": self.controller.outpost.uuid.hex,
|
||||||
"goauthentik.io/outpost-name": slugify(self.controller.outpost.name),
|
"goauthentik.io/outpost-name": slugify(self.controller.outpost.name),
|
||||||
"goauthentik.io/outpost-type": str(self.controller.outpost.type),
|
"goauthentik.io/outpost-type": str(self.controller.outpost.type),
|
||||||
}
|
}
|
||||||
|
)
|
||||||
|
return kwargs
|
||||||
|
|
||||||
def get_reference_object(self) -> V1Deployment:
|
def get_reference_object(self) -> V1Deployment:
|
||||||
"""Get deployment object for outpost"""
|
"""Get deployment object for outpost"""
|
||||||
@ -77,13 +81,24 @@ class DeploymentReconciler(KubernetesObjectReconciler[V1Deployment]):
|
|||||||
meta = self.get_object_meta(name=self.name)
|
meta = self.get_object_meta(name=self.name)
|
||||||
image_name = self.controller.get_container_image()
|
image_name = self.controller.get_container_image()
|
||||||
image_pull_secrets = self.outpost.config.kubernetes_image_pull_secrets
|
image_pull_secrets = self.outpost.config.kubernetes_image_pull_secrets
|
||||||
|
version = get_full_version()
|
||||||
return V1Deployment(
|
return V1Deployment(
|
||||||
metadata=meta,
|
metadata=meta,
|
||||||
spec=V1DeploymentSpec(
|
spec=V1DeploymentSpec(
|
||||||
replicas=self.outpost.config.kubernetes_replicas,
|
replicas=self.outpost.config.kubernetes_replicas,
|
||||||
selector=V1LabelSelector(match_labels=self.get_pod_meta()),
|
selector=V1LabelSelector(match_labels=self.get_pod_meta()),
|
||||||
template=V1PodTemplateSpec(
|
template=V1PodTemplateSpec(
|
||||||
metadata=V1ObjectMeta(labels=self.get_pod_meta()),
|
metadata=V1ObjectMeta(
|
||||||
|
labels=self.get_pod_meta(
|
||||||
|
**{
|
||||||
|
# Support istio-specific labels, but also use the standard k8s
|
||||||
|
# recommendations
|
||||||
|
"app.kubernetes.io/version": version,
|
||||||
|
"app": "authentik-outpost",
|
||||||
|
"version": version,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
),
|
||||||
spec=V1PodSpec(
|
spec=V1PodSpec(
|
||||||
image_pull_secrets=[
|
image_pull_secrets=[
|
||||||
V1ObjectReference(name=secret) for secret in image_pull_secrets
|
V1ObjectReference(name=secret) for secret in image_pull_secrets
|
||||||
|
|||||||
@ -6,6 +6,7 @@ from kubernetes.client import CoreV1Api, V1Service, V1ServicePort, V1ServiceSpec
|
|||||||
from authentik.outposts.controllers.base import FIELD_MANAGER
|
from authentik.outposts.controllers.base import FIELD_MANAGER
|
||||||
from authentik.outposts.controllers.k8s.base import KubernetesObjectReconciler
|
from authentik.outposts.controllers.k8s.base import KubernetesObjectReconciler
|
||||||
from authentik.outposts.controllers.k8s.deployment import DeploymentReconciler
|
from authentik.outposts.controllers.k8s.deployment import DeploymentReconciler
|
||||||
|
from authentik.outposts.controllers.k8s.triggers import NeedsUpdate
|
||||||
from authentik.outposts.controllers.k8s.utils import compare_ports
|
from authentik.outposts.controllers.k8s.utils import compare_ports
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
@ -25,6 +26,8 @@ class ServiceReconciler(KubernetesObjectReconciler[V1Service]):
|
|||||||
# after an authentik update. However the ports might have also changed during
|
# after an authentik update. However the ports might have also changed during
|
||||||
# the update, so this causes the service to be re-created with higher
|
# the update, so this causes the service to be re-created with higher
|
||||||
# priority than being updated.
|
# priority than being updated.
|
||||||
|
if current.spec.selector != reference.spec.selector:
|
||||||
|
raise NeedsUpdate()
|
||||||
super().reconcile(current, reference)
|
super().reconcile(current, reference)
|
||||||
|
|
||||||
def get_reference_object(self) -> V1Service:
|
def get_reference_object(self) -> V1Service:
|
||||||
|
|||||||
@ -2,6 +2,7 @@
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from kubernetes.client.models.v1_container_port import V1ContainerPort
|
from kubernetes.client.models.v1_container_port import V1ContainerPort
|
||||||
|
from kubernetes.client.models.v1_service_port import V1ServicePort
|
||||||
from kubernetes.config.incluster_config import SERVICE_TOKEN_FILENAME
|
from kubernetes.config.incluster_config import SERVICE_TOKEN_FILENAME
|
||||||
|
|
||||||
from authentik.outposts.controllers.k8s.triggers import NeedsRecreate
|
from authentik.outposts.controllers.k8s.triggers import NeedsRecreate
|
||||||
@ -16,10 +17,31 @@ def get_namespace() -> str:
|
|||||||
return "default"
|
return "default"
|
||||||
|
|
||||||
|
|
||||||
def compare_ports(current: list[V1ContainerPort], reference: list[V1ContainerPort]):
|
def compare_port(
|
||||||
|
current: V1ServicePort | V1ContainerPort, reference: V1ServicePort | V1ContainerPort
|
||||||
|
) -> bool:
|
||||||
|
"""Compare a single port"""
|
||||||
|
if current.name != reference.name:
|
||||||
|
return False
|
||||||
|
if current.protocol != reference.protocol:
|
||||||
|
return False
|
||||||
|
if isinstance(current, V1ServicePort) and isinstance(reference, V1ServicePort):
|
||||||
|
# We only care about the target port
|
||||||
|
if current.target_port != reference.target_port:
|
||||||
|
return False
|
||||||
|
if isinstance(current, V1ContainerPort) and isinstance(reference, V1ContainerPort):
|
||||||
|
# We only care about the target port
|
||||||
|
if current.container_port != reference.container_port:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def compare_ports(
|
||||||
|
current: list[V1ServicePort | V1ContainerPort], reference: list[V1ServicePort | V1ContainerPort]
|
||||||
|
):
|
||||||
"""Compare ports of a list"""
|
"""Compare ports of a list"""
|
||||||
if len(current) != len(reference):
|
if len(current) != len(reference):
|
||||||
raise NeedsRecreate()
|
raise NeedsRecreate()
|
||||||
for port in reference:
|
for port in reference:
|
||||||
if port not in current:
|
if not any(compare_port(port, current_port) for current_port in current):
|
||||||
raise NeedsRecreate()
|
raise NeedsRecreate()
|
||||||
|
|||||||
@ -3,6 +3,8 @@ import os
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from tempfile import gettempdir
|
from tempfile import gettempdir
|
||||||
|
|
||||||
|
from docker.errors import DockerException
|
||||||
|
|
||||||
from authentik.crypto.models import CertificateKeyPair
|
from authentik.crypto.models import CertificateKeyPair
|
||||||
|
|
||||||
HEADER = "### Managed by authentik"
|
HEADER = "### Managed by authentik"
|
||||||
@ -27,6 +29,8 @@ class DockerInlineSSH:
|
|||||||
def __init__(self, host: str, keypair: CertificateKeyPair) -> None:
|
def __init__(self, host: str, keypair: CertificateKeyPair) -> None:
|
||||||
self.host = host
|
self.host = host
|
||||||
self.keypair = keypair
|
self.keypair = keypair
|
||||||
|
if not self.keypair:
|
||||||
|
raise DockerException("keypair must be set for SSH connections")
|
||||||
self.config_path = Path("~/.ssh/config").expanduser()
|
self.config_path = Path("~/.ssh/config").expanduser()
|
||||||
self.header = f"{HEADER} - {self.host}\n"
|
self.header = f"{HEADER} - {self.host}\n"
|
||||||
|
|
||||||
|
|||||||
@ -1,7 +1,6 @@
|
|||||||
"""Outpost models"""
|
"""Outpost models"""
|
||||||
from dataclasses import asdict, dataclass, field
|
from dataclasses import asdict, dataclass, field
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from os import environ
|
|
||||||
from typing import Iterable, Optional
|
from typing import Iterable, Optional
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
@ -17,7 +16,7 @@ from model_utils.managers import InheritanceManager
|
|||||||
from packaging.version import LegacyVersion, Version, parse
|
from packaging.version import LegacyVersion, Version, parse
|
||||||
from structlog.stdlib import get_logger
|
from structlog.stdlib import get_logger
|
||||||
|
|
||||||
from authentik import ENV_GIT_HASH_KEY, __version__
|
from authentik import __version__, get_build_hash
|
||||||
from authentik.core.models import (
|
from authentik.core.models import (
|
||||||
USER_ATTRIBUTE_CAN_OVERRIDE_IP,
|
USER_ATTRIBUTE_CAN_OVERRIDE_IP,
|
||||||
USER_ATTRIBUTE_SA,
|
USER_ATTRIBUTE_SA,
|
||||||
@ -61,6 +60,7 @@ class OutpostConfig:
|
|||||||
|
|
||||||
docker_network: Optional[str] = field(default=None)
|
docker_network: Optional[str] = field(default=None)
|
||||||
docker_map_ports: bool = field(default=True)
|
docker_map_ports: bool = field(default=True)
|
||||||
|
docker_labels: Optional[dict[str, str]] = field(default=None)
|
||||||
|
|
||||||
container_image: Optional[str] = field(default=None)
|
container_image: Optional[str] = field(default=None)
|
||||||
|
|
||||||
@ -414,7 +414,7 @@ class OutpostState:
|
|||||||
"""Check if outpost version matches our version"""
|
"""Check if outpost version matches our version"""
|
||||||
if not self.version:
|
if not self.version:
|
||||||
return False
|
return False
|
||||||
if self.build_hash != environ.get(ENV_GIT_HASH_KEY, ""):
|
if self.build_hash != get_build_hash():
|
||||||
return False
|
return False
|
||||||
return parse(self.version) < OUR_VERSION
|
return parse(self.version) < OUR_VERSION
|
||||||
|
|
||||||
|
|||||||
@ -23,6 +23,7 @@ from authentik.events.monitored_tasks import (
|
|||||||
TaskResultStatus,
|
TaskResultStatus,
|
||||||
prefill_task,
|
prefill_task,
|
||||||
)
|
)
|
||||||
|
from authentik.lib.config import CONFIG
|
||||||
from authentik.lib.utils.reflection import path_to_class
|
from authentik.lib.utils.reflection import path_to_class
|
||||||
from authentik.outposts.controllers.base import BaseController, ControllerException
|
from authentik.outposts.controllers.base import BaseController, ControllerException
|
||||||
from authentik.outposts.controllers.docker import DockerClient
|
from authentik.outposts.controllers.docker import DockerClient
|
||||||
@ -77,8 +78,12 @@ def outpost_service_connection_state(connection_pk: Any):
|
|||||||
cls = DockerClient
|
cls = DockerClient
|
||||||
if isinstance(connection, KubernetesServiceConnection):
|
if isinstance(connection, KubernetesServiceConnection):
|
||||||
cls = KubernetesClient
|
cls = KubernetesClient
|
||||||
|
try:
|
||||||
with cls(connection) as client:
|
with cls(connection) as client:
|
||||||
state = client.fetch_state()
|
state = client.fetch_state()
|
||||||
|
except ServiceConnectionInvalid as exc:
|
||||||
|
LOGGER.warning("Failed to get client status", exc=exc)
|
||||||
|
return
|
||||||
cache.set(connection.state_key, state, timeout=None)
|
cache.set(connection.state_key, state, timeout=None)
|
||||||
|
|
||||||
|
|
||||||
@ -227,6 +232,9 @@ def _outpost_single_update(outpost: Outpost, layer=None):
|
|||||||
@CELERY_APP.task()
|
@CELERY_APP.task()
|
||||||
def outpost_local_connection():
|
def outpost_local_connection():
|
||||||
"""Checks the local environment and create Service connections."""
|
"""Checks the local environment and create Service connections."""
|
||||||
|
if not CONFIG.y_bool("outposts.discover"):
|
||||||
|
LOGGER.debug("outpost integration discovery is disabled")
|
||||||
|
return
|
||||||
# Explicitly check against token filename, as that's
|
# Explicitly check against token filename, as that's
|
||||||
# only present when the integration is enabled
|
# only present when the integration is enabled
|
||||||
if Path(SERVICE_TOKEN_FILENAME).exists():
|
if Path(SERVICE_TOKEN_FILENAME).exists():
|
||||||
|
|||||||
@ -5,7 +5,7 @@ from typing import Iterator, 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 Histogram
|
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
|
||||||
@ -14,13 +14,11 @@ from authentik.core.models import User
|
|||||||
from authentik.policies.models import Policy, PolicyBinding, PolicyBindingModel, PolicyEngineMode
|
from authentik.policies.models import Policy, PolicyBinding, PolicyBindingModel, PolicyEngineMode
|
||||||
from authentik.policies.process import PolicyProcess, cache_key
|
from authentik.policies.process import PolicyProcess, cache_key
|
||||||
from authentik.policies.types import PolicyRequest, PolicyResult
|
from authentik.policies.types import PolicyRequest, PolicyResult
|
||||||
from authentik.root.monitoring import UpdatingGauge
|
|
||||||
|
|
||||||
CURRENT_PROCESS = current_process()
|
CURRENT_PROCESS = current_process()
|
||||||
GAUGE_POLICIES_CACHED = UpdatingGauge(
|
GAUGE_POLICIES_CACHED = Gauge(
|
||||||
"authentik_policies_cached",
|
"authentik_policies_cached",
|
||||||
"Cached Policies",
|
"Cached Policies",
|
||||||
update_func=lambda: len(cache.keys("policy_*") or []),
|
|
||||||
)
|
)
|
||||||
HIST_POLICIES_BUILD_TIME = Histogram(
|
HIST_POLICIES_BUILD_TIME = Histogram(
|
||||||
"authentik_policies_build_time",
|
"authentik_policies_build_time",
|
||||||
|
|||||||
@ -45,7 +45,7 @@ class HaveIBeenPwendPolicy(Policy):
|
|||||||
fields=request.context.keys(),
|
fields=request.context.keys(),
|
||||||
)
|
)
|
||||||
return PolicyResult(False, _("Password not set in context"))
|
return PolicyResult(False, _("Password not set in context"))
|
||||||
password = request.context[self.password_field]
|
password = str(request.context[self.password_field])
|
||||||
|
|
||||||
pw_hash = sha1(password.encode("utf-8")).hexdigest() # nosec
|
pw_hash = sha1(password.encode("utf-8")).hexdigest() # nosec
|
||||||
url = f"https://api.pwnedpasswords.com/range/{pw_hash[:5]}"
|
url = f"https://api.pwnedpasswords.com/range/{pw_hash[:5]}"
|
||||||
|
|||||||
@ -5,10 +5,19 @@ from django.dispatch import receiver
|
|||||||
from structlog.stdlib import get_logger
|
from structlog.stdlib import get_logger
|
||||||
|
|
||||||
from authentik.core.api.applications import user_app_cache_key
|
from authentik.core.api.applications import user_app_cache_key
|
||||||
|
from authentik.policies.engine import GAUGE_POLICIES_CACHED
|
||||||
|
from authentik.root.monitoring import monitoring_set
|
||||||
|
|
||||||
LOGGER = get_logger()
|
LOGGER = get_logger()
|
||||||
|
|
||||||
|
|
||||||
|
@receiver(monitoring_set)
|
||||||
|
# pylint: disable=unused-argument
|
||||||
|
def monitoring_set_policies(sender, **kwargs):
|
||||||
|
"""set policy gauges"""
|
||||||
|
GAUGE_POLICIES_CACHED.set(len(cache.keys("policy_*") or []))
|
||||||
|
|
||||||
|
|
||||||
@receiver(post_save)
|
@receiver(post_save)
|
||||||
# pylint: disable=unused-argument
|
# pylint: disable=unused-argument
|
||||||
def invalidate_policy_cache(sender, instance, **_):
|
def invalidate_policy_cache(sender, instance, **_):
|
||||||
|
|||||||
@ -2,9 +2,12 @@
|
|||||||
|
|
||||||
GRANT_TYPE_AUTHORIZATION_CODE = "authorization_code"
|
GRANT_TYPE_AUTHORIZATION_CODE = "authorization_code"
|
||||||
GRANT_TYPE_REFRESH_TOKEN = "refresh_token" # nosec
|
GRANT_TYPE_REFRESH_TOKEN = "refresh_token" # nosec
|
||||||
|
GRANT_TYPE_CLIENT_CREDENTIALS = "client_credentials"
|
||||||
|
|
||||||
PROMPT_NONE = "none"
|
PROMPT_NONE = "none"
|
||||||
PROMPT_CONSNET = "consent"
|
PROMPT_CONSNET = "consent"
|
||||||
PROMPT_LOGIN = "login"
|
PROMPT_LOGIN = "login"
|
||||||
|
|
||||||
SCOPE_OPENID = "openid"
|
SCOPE_OPENID = "openid"
|
||||||
SCOPE_OPENID_PROFILE = "profile"
|
SCOPE_OPENID_PROFILE = "profile"
|
||||||
SCOPE_OPENID_EMAIL = "email"
|
SCOPE_OPENID_EMAIL = "email"
|
||||||
|
|||||||
@ -168,7 +168,7 @@ class TokenError(OAuth2Error):
|
|||||||
https://tools.ietf.org/html/rfc6749#section-5.2
|
https://tools.ietf.org/html/rfc6749#section-5.2
|
||||||
"""
|
"""
|
||||||
|
|
||||||
_errors = {
|
errors = {
|
||||||
"invalid_request": "The request is otherwise malformed",
|
"invalid_request": "The request is otherwise malformed",
|
||||||
"invalid_client": "Client authentication failed (e.g., unknown client, "
|
"invalid_client": "Client authentication failed (e.g., unknown client, "
|
||||||
"no client authentication included, or unsupported "
|
"no client authentication included, or unsupported "
|
||||||
@ -188,7 +188,7 @@ class TokenError(OAuth2Error):
|
|||||||
def __init__(self, error):
|
def __init__(self, error):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.error = error
|
self.error = error
|
||||||
self.description = self._errors[error]
|
self.description = self.errors[error]
|
||||||
|
|
||||||
|
|
||||||
class BearerTokenError(OAuth2Error):
|
class BearerTokenError(OAuth2Error):
|
||||||
|
|||||||
@ -7,7 +7,7 @@ from dataclasses import asdict, dataclass, field
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from hashlib import sha256
|
from hashlib import sha256
|
||||||
from typing import Any, Optional
|
from typing import Any, Optional
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse, urlunparse
|
||||||
|
|
||||||
from cryptography.hazmat.primitives.asymmetric.ec import EllipticCurvePrivateKey
|
from cryptography.hazmat.primitives.asymmetric.ec import EllipticCurvePrivateKey
|
||||||
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey
|
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey
|
||||||
@ -45,6 +45,13 @@ class GrantTypes(models.TextChoices):
|
|||||||
HYBRID = "hybrid"
|
HYBRID = "hybrid"
|
||||||
|
|
||||||
|
|
||||||
|
class ResponseMode(models.TextChoices):
|
||||||
|
"""https://openid.net/specs/oauth-v2-multiple-response-types-1_0.html#OAuth.Post"""
|
||||||
|
|
||||||
|
QUERY = "query"
|
||||||
|
FRAGMENT = "fragment"
|
||||||
|
|
||||||
|
|
||||||
class SubModes(models.TextChoices):
|
class SubModes(models.TextChoices):
|
||||||
"""Mode after which 'sub' attribute is generateed, for compatibility reasons"""
|
"""Mode after which 'sub' attribute is generateed, for compatibility reasons"""
|
||||||
|
|
||||||
@ -259,8 +266,8 @@ class OAuth2Provider(Provider):
|
|||||||
if self.redirect_uris == "":
|
if self.redirect_uris == "":
|
||||||
return None
|
return None
|
||||||
main_url = self.redirect_uris.split("\n", maxsplit=1)[0]
|
main_url = self.redirect_uris.split("\n", maxsplit=1)[0]
|
||||||
launch_url = urlparse(main_url)
|
launch_url = urlparse(main_url)._replace(path="")
|
||||||
return main_url.replace(launch_url.path, "")
|
return urlunparse(launch_url)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def component(self) -> str:
|
def component(self) -> str:
|
||||||
|
|||||||
@ -43,7 +43,7 @@ class TestAuthorize(OAuthTestCase):
|
|||||||
name="test",
|
name="test",
|
||||||
client_id="test",
|
client_id="test",
|
||||||
authorization_flow=create_test_flow(),
|
authorization_flow=create_test_flow(),
|
||||||
redirect_uris="http://local.invalid",
|
redirect_uris="http://local.invalid/Foo",
|
||||||
)
|
)
|
||||||
with self.assertRaises(AuthorizeError):
|
with self.assertRaises(AuthorizeError):
|
||||||
request = self.factory.get(
|
request = self.factory.get(
|
||||||
@ -51,7 +51,7 @@ class TestAuthorize(OAuthTestCase):
|
|||||||
data={
|
data={
|
||||||
"response_type": "code",
|
"response_type": "code",
|
||||||
"client_id": "test",
|
"client_id": "test",
|
||||||
"redirect_uri": "http://local.invalid",
|
"redirect_uri": "http://local.invalid/Foo",
|
||||||
"request": "foo",
|
"request": "foo",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@ -105,26 +105,30 @@ class TestAuthorize(OAuthTestCase):
|
|||||||
name="test",
|
name="test",
|
||||||
client_id="test",
|
client_id="test",
|
||||||
authorization_flow=create_test_flow(),
|
authorization_flow=create_test_flow(),
|
||||||
redirect_uris="http://local.invalid",
|
redirect_uris="http://local.invalid/Foo",
|
||||||
)
|
)
|
||||||
request = self.factory.get(
|
request = self.factory.get(
|
||||||
"/",
|
"/",
|
||||||
data={
|
data={
|
||||||
"response_type": "code",
|
"response_type": "code",
|
||||||
"client_id": "test",
|
"client_id": "test",
|
||||||
"redirect_uri": "http://local.invalid",
|
"redirect_uri": "http://local.invalid/Foo",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
OAuthAuthorizationParams.from_request(request).grant_type,
|
OAuthAuthorizationParams.from_request(request).grant_type,
|
||||||
GrantTypes.AUTHORIZATION_CODE,
|
GrantTypes.AUTHORIZATION_CODE,
|
||||||
)
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
OAuthAuthorizationParams.from_request(request).redirect_uri,
|
||||||
|
"http://local.invalid/Foo",
|
||||||
|
)
|
||||||
request = self.factory.get(
|
request = self.factory.get(
|
||||||
"/",
|
"/",
|
||||||
data={
|
data={
|
||||||
"response_type": "id_token",
|
"response_type": "id_token",
|
||||||
"client_id": "test",
|
"client_id": "test",
|
||||||
"redirect_uri": "http://local.invalid",
|
"redirect_uri": "http://local.invalid/Foo",
|
||||||
"scope": "openid",
|
"scope": "openid",
|
||||||
"state": "foo",
|
"state": "foo",
|
||||||
},
|
},
|
||||||
@ -140,7 +144,7 @@ class TestAuthorize(OAuthTestCase):
|
|||||||
data={
|
data={
|
||||||
"response_type": "id_token",
|
"response_type": "id_token",
|
||||||
"client_id": "test",
|
"client_id": "test",
|
||||||
"redirect_uri": "http://local.invalid",
|
"redirect_uri": "http://local.invalid/Foo",
|
||||||
"state": "foo",
|
"state": "foo",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@ -153,7 +157,7 @@ class TestAuthorize(OAuthTestCase):
|
|||||||
data={
|
data={
|
||||||
"response_type": "code token",
|
"response_type": "code token",
|
||||||
"client_id": "test",
|
"client_id": "test",
|
||||||
"redirect_uri": "http://local.invalid",
|
"redirect_uri": "http://local.invalid/Foo",
|
||||||
"scope": "openid",
|
"scope": "openid",
|
||||||
"state": "foo",
|
"state": "foo",
|
||||||
},
|
},
|
||||||
@ -167,7 +171,7 @@ class TestAuthorize(OAuthTestCase):
|
|||||||
data={
|
data={
|
||||||
"response_type": "invalid",
|
"response_type": "invalid",
|
||||||
"client_id": "test",
|
"client_id": "test",
|
||||||
"redirect_uri": "http://local.invalid",
|
"redirect_uri": "http://local.invalid/Foo",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
OAuthAuthorizationParams.from_request(request)
|
OAuthAuthorizationParams.from_request(request)
|
||||||
|
|||||||
@ -0,0 +1,174 @@
|
|||||||
|
"""Test token view"""
|
||||||
|
from json import loads
|
||||||
|
|
||||||
|
from django.test import RequestFactory
|
||||||
|
from django.urls import reverse
|
||||||
|
from jwt import decode
|
||||||
|
|
||||||
|
from authentik.core.models import USER_ATTRIBUTE_SA, Application, Group, Token, TokenIntents
|
||||||
|
from authentik.core.tests.utils import create_test_admin_user, create_test_cert, create_test_flow
|
||||||
|
from authentik.lib.generators import generate_id, generate_key
|
||||||
|
from authentik.managed.manager import ObjectManager
|
||||||
|
from authentik.policies.models import PolicyBinding
|
||||||
|
from authentik.providers.oauth2.constants import (
|
||||||
|
GRANT_TYPE_CLIENT_CREDENTIALS,
|
||||||
|
SCOPE_OPENID,
|
||||||
|
SCOPE_OPENID_EMAIL,
|
||||||
|
SCOPE_OPENID_PROFILE,
|
||||||
|
)
|
||||||
|
from authentik.providers.oauth2.errors import TokenError
|
||||||
|
from authentik.providers.oauth2.models import OAuth2Provider, ScopeMapping
|
||||||
|
from authentik.providers.oauth2.tests.utils import OAuthTestCase
|
||||||
|
|
||||||
|
|
||||||
|
class TestTokenClientCredentials(OAuthTestCase):
|
||||||
|
"""Test token (client_credentials) view"""
|
||||||
|
|
||||||
|
def setUp(self) -> None:
|
||||||
|
super().setUp()
|
||||||
|
ObjectManager().run()
|
||||||
|
self.factory = RequestFactory()
|
||||||
|
self.provider = OAuth2Provider.objects.create(
|
||||||
|
name="test",
|
||||||
|
client_id=generate_id(),
|
||||||
|
client_secret=generate_key(),
|
||||||
|
authorization_flow=create_test_flow(),
|
||||||
|
redirect_uris="http://testserver",
|
||||||
|
signing_key=create_test_cert(),
|
||||||
|
)
|
||||||
|
self.provider.property_mappings.set(ScopeMapping.objects.all())
|
||||||
|
self.app = Application.objects.create(name="test", slug="test", provider=self.provider)
|
||||||
|
self.user = create_test_admin_user("sa")
|
||||||
|
self.user.attributes[USER_ATTRIBUTE_SA] = True
|
||||||
|
self.user.save()
|
||||||
|
self.token = Token.objects.create(
|
||||||
|
identifier="sa-token",
|
||||||
|
user=self.user,
|
||||||
|
intent=TokenIntents.INTENT_APP_PASSWORD,
|
||||||
|
expiring=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_wrong_user(self):
|
||||||
|
"""test invalid username"""
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("authentik_providers_oauth2:token"),
|
||||||
|
{
|
||||||
|
"grant_type": GRANT_TYPE_CLIENT_CREDENTIALS,
|
||||||
|
"scope": SCOPE_OPENID,
|
||||||
|
"client_id": self.provider.client_id,
|
||||||
|
"username": "saa",
|
||||||
|
"password": self.token.key,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 400)
|
||||||
|
self.assertJSONEqual(
|
||||||
|
response.content.decode(),
|
||||||
|
{"error": "invalid_grant", "error_description": TokenError.errors["invalid_grant"]},
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_wrong_token(self):
|
||||||
|
"""test invalid token"""
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("authentik_providers_oauth2:token"),
|
||||||
|
{
|
||||||
|
"grant_type": GRANT_TYPE_CLIENT_CREDENTIALS,
|
||||||
|
"scope": SCOPE_OPENID,
|
||||||
|
"client_id": self.provider.client_id,
|
||||||
|
"username": "sa",
|
||||||
|
"password": self.token.key + "foo",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 400)
|
||||||
|
self.assertJSONEqual(
|
||||||
|
response.content.decode(),
|
||||||
|
{"error": "invalid_grant", "error_description": TokenError.errors["invalid_grant"]},
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_non_sa(self):
|
||||||
|
"""test non service-account"""
|
||||||
|
self.user.attributes[USER_ATTRIBUTE_SA] = False
|
||||||
|
self.user.save()
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("authentik_providers_oauth2:token"),
|
||||||
|
{
|
||||||
|
"grant_type": GRANT_TYPE_CLIENT_CREDENTIALS,
|
||||||
|
"scope": SCOPE_OPENID,
|
||||||
|
"client_id": self.provider.client_id,
|
||||||
|
"username": "sa",
|
||||||
|
"password": self.token.key,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 400)
|
||||||
|
self.assertJSONEqual(
|
||||||
|
response.content.decode(),
|
||||||
|
{"error": "invalid_grant", "error_description": TokenError.errors["invalid_grant"]},
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_no_provider(self):
|
||||||
|
"""test no provider"""
|
||||||
|
self.app.provider = None
|
||||||
|
self.app.save()
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("authentik_providers_oauth2:token"),
|
||||||
|
{
|
||||||
|
"grant_type": GRANT_TYPE_CLIENT_CREDENTIALS,
|
||||||
|
"scope": SCOPE_OPENID,
|
||||||
|
"client_id": self.provider.client_id,
|
||||||
|
"username": "sa",
|
||||||
|
"password": self.token.key,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 400)
|
||||||
|
self.assertJSONEqual(
|
||||||
|
response.content.decode(),
|
||||||
|
{"error": "invalid_grant", "error_description": TokenError.errors["invalid_grant"]},
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_permission_denied(self):
|
||||||
|
"""test permission denied"""
|
||||||
|
group = Group.objects.create(name="foo")
|
||||||
|
PolicyBinding.objects.create(
|
||||||
|
group=group,
|
||||||
|
target=self.app,
|
||||||
|
order=0,
|
||||||
|
)
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("authentik_providers_oauth2:token"),
|
||||||
|
{
|
||||||
|
"grant_type": GRANT_TYPE_CLIENT_CREDENTIALS,
|
||||||
|
"scope": SCOPE_OPENID,
|
||||||
|
"client_id": self.provider.client_id,
|
||||||
|
"username": "sa",
|
||||||
|
"password": self.token.key,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 400)
|
||||||
|
self.assertJSONEqual(
|
||||||
|
response.content.decode(),
|
||||||
|
{"error": "invalid_grant", "error_description": TokenError.errors["invalid_grant"]},
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_successful(self):
|
||||||
|
"""test successful"""
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("authentik_providers_oauth2:token"),
|
||||||
|
{
|
||||||
|
"grant_type": GRANT_TYPE_CLIENT_CREDENTIALS,
|
||||||
|
"scope": f"{SCOPE_OPENID} {SCOPE_OPENID_EMAIL} {SCOPE_OPENID_PROFILE}",
|
||||||
|
"client_id": self.provider.client_id,
|
||||||
|
"username": "sa",
|
||||||
|
"password": self.token.key,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
body = loads(response.content.decode())
|
||||||
|
self.assertEqual(body["token_type"], "bearer")
|
||||||
|
_, alg = self.provider.get_jwt_key()
|
||||||
|
jwt = decode(
|
||||||
|
body["access_token"],
|
||||||
|
key=self.provider.signing_key.public_key,
|
||||||
|
algorithms=[alg],
|
||||||
|
audience=self.provider.client_id,
|
||||||
|
)
|
||||||
|
self.assertEqual(jwt["given_name"], self.user.name)
|
||||||
|
self.assertEqual(jwt["preferred_username"], self.user.username)
|
||||||
@ -44,6 +44,7 @@ from authentik.providers.oauth2.models import (
|
|||||||
AuthorizationCode,
|
AuthorizationCode,
|
||||||
GrantTypes,
|
GrantTypes,
|
||||||
OAuth2Provider,
|
OAuth2Provider,
|
||||||
|
ResponseMode,
|
||||||
ResponseTypes,
|
ResponseTypes,
|
||||||
)
|
)
|
||||||
from authentik.providers.oauth2.utils import HttpResponseRedirectScheme
|
from authentik.providers.oauth2.utils import HttpResponseRedirectScheme
|
||||||
@ -153,16 +154,26 @@ class OAuthAuthorizationParams:
|
|||||||
def check_redirect_uri(self):
|
def check_redirect_uri(self):
|
||||||
"""Redirect URI validation."""
|
"""Redirect URI validation."""
|
||||||
allowed_redirect_urls = self.provider.redirect_uris.split()
|
allowed_redirect_urls = self.provider.redirect_uris.split()
|
||||||
if not self.redirect_uri:
|
# We don't want to actually lowercase the final URL we redirect to,
|
||||||
|
# we only lowercase it for comparison
|
||||||
|
redirect_uri = self.redirect_uri.lower()
|
||||||
|
if not redirect_uri:
|
||||||
LOGGER.warning("Missing redirect uri.")
|
LOGGER.warning("Missing redirect uri.")
|
||||||
raise RedirectUriError("", allowed_redirect_urls)
|
raise RedirectUriError("", allowed_redirect_urls)
|
||||||
if len(allowed_redirect_urls) < 1:
|
|
||||||
|
if self.provider.redirect_uris == "":
|
||||||
|
LOGGER.info("Setting redirect for blank redirect_uris", redirect=self.redirect_uri)
|
||||||
|
self.provider.redirect_uris = self.redirect_uri
|
||||||
|
self.provider.save()
|
||||||
|
allowed_redirect_urls = self.provider.redirect_uris.split()
|
||||||
|
|
||||||
|
if self.provider.redirect_uris == "*":
|
||||||
LOGGER.warning(
|
LOGGER.warning(
|
||||||
"Provider has no allowed redirect_uri set, allowing all.",
|
"Provider has wildcard allowed redirect_uri set, allowing all.",
|
||||||
allow=self.redirect_uri.lower(),
|
allow=self.redirect_uri,
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
if self.redirect_uri.lower() not in [x.lower() for x in allowed_redirect_urls]:
|
if redirect_uri not in [x.lower() for x in allowed_redirect_urls]:
|
||||||
LOGGER.warning(
|
LOGGER.warning(
|
||||||
"Invalid redirect uri",
|
"Invalid redirect uri",
|
||||||
redirect_uri=self.redirect_uri,
|
redirect_uri=self.redirect_uri,
|
||||||
@ -292,13 +303,23 @@ class OAuthFulfillmentStage(StageView):
|
|||||||
code = self.params.create_code(self.request)
|
code = self.params.create_code(self.request)
|
||||||
code.save(force_insert=True)
|
code.save(force_insert=True)
|
||||||
|
|
||||||
if self.params.grant_type == GrantTypes.AUTHORIZATION_CODE:
|
query_dict = self.request.POST if self.request.method == "POST" else self.request.GET
|
||||||
|
response_mode = ResponseMode.QUERY
|
||||||
|
# Get response mode from url param, otherwise decide based on grant type
|
||||||
|
if "response_mode" in query_dict:
|
||||||
|
response_mode = query_dict["response_mode"]
|
||||||
|
elif self.params.grant_type == GrantTypes.AUTHORIZATION_CODE:
|
||||||
|
response_mode = ResponseMode.QUERY
|
||||||
|
elif self.params.grant_type in [GrantTypes.IMPLICIT, GrantTypes.HYBRID]:
|
||||||
|
response_mode = ResponseMode.FRAGMENT
|
||||||
|
|
||||||
|
if response_mode == ResponseMode.QUERY:
|
||||||
query_params["code"] = code.code
|
query_params["code"] = code.code
|
||||||
query_params["state"] = [str(self.params.state) if self.params.state else ""]
|
query_params["state"] = [str(self.params.state) if self.params.state else ""]
|
||||||
|
|
||||||
uri = uri._replace(query=urlencode(query_params, doseq=True))
|
uri = uri._replace(query=urlencode(query_params, doseq=True))
|
||||||
return urlunsplit(uri)
|
return urlunsplit(uri)
|
||||||
if self.params.grant_type in [GrantTypes.IMPLICIT, GrantTypes.HYBRID]:
|
if response_mode == ResponseMode.FRAGMENT:
|
||||||
query_fragment = self.create_implicit_response(code)
|
query_fragment = self.create_implicit_response(code)
|
||||||
|
|
||||||
uri = uri._replace(
|
uri = uri._replace(
|
||||||
|
|||||||
@ -10,6 +10,7 @@ from authentik.core.models import Application
|
|||||||
from authentik.providers.oauth2.constants import (
|
from authentik.providers.oauth2.constants import (
|
||||||
ACR_AUTHENTIK_DEFAULT,
|
ACR_AUTHENTIK_DEFAULT,
|
||||||
GRANT_TYPE_AUTHORIZATION_CODE,
|
GRANT_TYPE_AUTHORIZATION_CODE,
|
||||||
|
GRANT_TYPE_CLIENT_CREDENTIALS,
|
||||||
GRANT_TYPE_REFRESH_TOKEN,
|
GRANT_TYPE_REFRESH_TOKEN,
|
||||||
SCOPE_OPENID,
|
SCOPE_OPENID,
|
||||||
)
|
)
|
||||||
@ -78,6 +79,7 @@ class ProviderInfoView(View):
|
|||||||
GRANT_TYPE_AUTHORIZATION_CODE,
|
GRANT_TYPE_AUTHORIZATION_CODE,
|
||||||
GRANT_TYPE_REFRESH_TOKEN,
|
GRANT_TYPE_REFRESH_TOKEN,
|
||||||
GrantTypes.IMPLICIT,
|
GrantTypes.IMPLICIT,
|
||||||
|
GRANT_TYPE_CLIENT_CREDENTIALS,
|
||||||
],
|
],
|
||||||
"id_token_signing_alg_values_supported": [supported_alg],
|
"id_token_signing_alg_values_supported": [supported_alg],
|
||||||
# See: http://openid.net/specs/openid-connect-core-1_0.html#SubjectIDTypes
|
# See: http://openid.net/specs/openid-connect-core-1_0.html#SubjectIDTypes
|
||||||
|
|||||||
@ -8,10 +8,13 @@ from django.http import HttpRequest, HttpResponse
|
|||||||
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.models import USER_ATTRIBUTE_SA, Application, Token, TokenIntents, User
|
||||||
from authentik.events.models import Event, EventAction
|
from authentik.events.models import Event, EventAction
|
||||||
from authentik.lib.utils.time import timedelta_from_string
|
from authentik.lib.utils.time import timedelta_from_string
|
||||||
|
from authentik.policies.engine import PolicyEngine
|
||||||
from authentik.providers.oauth2.constants import (
|
from authentik.providers.oauth2.constants import (
|
||||||
GRANT_TYPE_AUTHORIZATION_CODE,
|
GRANT_TYPE_AUTHORIZATION_CODE,
|
||||||
|
GRANT_TYPE_CLIENT_CREDENTIALS,
|
||||||
GRANT_TYPE_REFRESH_TOKEN,
|
GRANT_TYPE_REFRESH_TOKEN,
|
||||||
)
|
)
|
||||||
from authentik.providers.oauth2.errors import TokenError, UserAuthError
|
from authentik.providers.oauth2.errors import TokenError, UserAuthError
|
||||||
@ -42,6 +45,7 @@ class TokenParams:
|
|||||||
|
|
||||||
authorization_code: Optional[AuthorizationCode] = None
|
authorization_code: Optional[AuthorizationCode] = None
|
||||||
refresh_token: Optional[RefreshToken] = None
|
refresh_token: Optional[RefreshToken] = None
|
||||||
|
user: Optional[User] = None
|
||||||
|
|
||||||
code_verifier: Optional[str] = None
|
code_verifier: Optional[str] = None
|
||||||
|
|
||||||
@ -66,7 +70,7 @@ class TokenParams:
|
|||||||
provider=provider,
|
provider=provider,
|
||||||
client_id=client_id,
|
client_id=client_id,
|
||||||
client_secret=client_secret,
|
client_secret=client_secret,
|
||||||
redirect_uri=request.POST.get("redirect_uri", ""),
|
redirect_uri=request.POST.get("redirect_uri", "").lower(),
|
||||||
grant_type=request.POST.get("grant_type", ""),
|
grant_type=request.POST.get("grant_type", ""),
|
||||||
state=request.POST.get("state", ""),
|
state=request.POST.get("state", ""),
|
||||||
scope=request.POST.get("scope", "").split(),
|
scope=request.POST.get("scope", "").split(),
|
||||||
@ -75,69 +79,44 @@ class TokenParams:
|
|||||||
)
|
)
|
||||||
|
|
||||||
def __post_init__(self, raw_code: str, raw_token: str, request: HttpRequest):
|
def __post_init__(self, raw_code: str, raw_token: str, request: HttpRequest):
|
||||||
if self.provider.client_type == ClientTypes.CONFIDENTIAL:
|
if self.grant_type in [GRANT_TYPE_AUTHORIZATION_CODE, GRANT_TYPE_REFRESH_TOKEN]:
|
||||||
if self.provider.client_secret != self.client_secret:
|
if (
|
||||||
|
self.provider.client_type == ClientTypes.CONFIDENTIAL
|
||||||
|
and self.provider.client_secret != self.client_secret
|
||||||
|
):
|
||||||
LOGGER.warning(
|
LOGGER.warning(
|
||||||
"Invalid client secret: client does not have secret",
|
"Invalid client secret",
|
||||||
client_id=self.provider.client_id,
|
client_id=self.provider.client_id,
|
||||||
secret=self.provider.client_secret,
|
|
||||||
)
|
)
|
||||||
raise TokenError("invalid_client")
|
raise TokenError("invalid_client")
|
||||||
|
|
||||||
if self.grant_type == GRANT_TYPE_AUTHORIZATION_CODE:
|
if self.grant_type == GRANT_TYPE_AUTHORIZATION_CODE:
|
||||||
self.__post_init_code(raw_code)
|
self.__post_init_code(raw_code)
|
||||||
elif self.grant_type == GRANT_TYPE_REFRESH_TOKEN:
|
elif self.grant_type == GRANT_TYPE_REFRESH_TOKEN:
|
||||||
if not raw_token:
|
self.__post_init_refresh(raw_token, request)
|
||||||
LOGGER.warning("Missing refresh token")
|
elif self.grant_type == GRANT_TYPE_CLIENT_CREDENTIALS:
|
||||||
raise TokenError("invalid_grant")
|
self.__post_init_client_credentials(request)
|
||||||
|
|
||||||
try:
|
|
||||||
self.refresh_token = RefreshToken.objects.get(
|
|
||||||
refresh_token=raw_token, provider=self.provider
|
|
||||||
)
|
|
||||||
if self.refresh_token.is_expired:
|
|
||||||
LOGGER.warning(
|
|
||||||
"Refresh token is expired",
|
|
||||||
token=raw_token,
|
|
||||||
)
|
|
||||||
raise TokenError("invalid_grant")
|
|
||||||
# https://tools.ietf.org/html/rfc6749#section-6
|
|
||||||
# Fallback to original token's scopes when none are given
|
|
||||||
if not self.scope:
|
|
||||||
self.scope = self.refresh_token.scope
|
|
||||||
except RefreshToken.DoesNotExist:
|
|
||||||
LOGGER.warning(
|
|
||||||
"Refresh token does not exist",
|
|
||||||
token=raw_token,
|
|
||||||
)
|
|
||||||
raise TokenError("invalid_grant")
|
|
||||||
if self.refresh_token.revoked:
|
|
||||||
LOGGER.warning("Refresh token is revoked", token=raw_token)
|
|
||||||
Event.new(
|
|
||||||
action=EventAction.SUSPICIOUS_REQUEST,
|
|
||||||
message="Revoked refresh token was used",
|
|
||||||
token=raw_token,
|
|
||||||
).from_http(request)
|
|
||||||
raise TokenError("invalid_grant")
|
|
||||||
else:
|
else:
|
||||||
LOGGER.warning("Invalid grant type", grant_type=self.grant_type)
|
LOGGER.warning("Invalid grant type", grant_type=self.grant_type)
|
||||||
raise TokenError("unsupported_grant_type")
|
raise TokenError("unsupported_grant_type")
|
||||||
|
|
||||||
def __post_init_code(self, raw_code):
|
def __post_init_code(self, raw_code: str):
|
||||||
if not raw_code:
|
if not raw_code:
|
||||||
LOGGER.warning("Missing authorization code")
|
LOGGER.warning("Missing authorization code")
|
||||||
raise TokenError("invalid_grant")
|
raise TokenError("invalid_grant")
|
||||||
|
|
||||||
allowed_redirect_urls = self.provider.redirect_uris.split()
|
allowed_redirect_urls = self.provider.redirect_uris.split()
|
||||||
if len(allowed_redirect_urls) < 1:
|
if self.provider.redirect_uris == "*":
|
||||||
LOGGER.warning(
|
LOGGER.warning(
|
||||||
"Provider has no allowed redirect_uri set, allowing all.",
|
"Provider has wildcard allowed redirect_uri set, allowing all.",
|
||||||
allow=self.redirect_uri.lower(),
|
redirect=self.redirect_uri,
|
||||||
)
|
)
|
||||||
elif self.redirect_uri.lower() not in [x.lower() for x in allowed_redirect_urls]:
|
# At this point, no provider should have a blank redirect_uri, in case they do
|
||||||
|
# this will check an empty array and raise an error
|
||||||
|
elif self.redirect_uri not in [x.lower() for x in allowed_redirect_urls]:
|
||||||
LOGGER.warning(
|
LOGGER.warning(
|
||||||
"Invalid redirect uri",
|
"Invalid redirect uri",
|
||||||
uri=self.redirect_uri,
|
redirect=self.redirect_uri,
|
||||||
expected=self.provider.redirect_uris.split(),
|
expected=self.provider.redirect_uris.split(),
|
||||||
)
|
)
|
||||||
raise TokenError("invalid_client")
|
raise TokenError("invalid_client")
|
||||||
@ -173,6 +152,77 @@ class TokenParams:
|
|||||||
LOGGER.warning("Code challenge not matching")
|
LOGGER.warning("Code challenge not matching")
|
||||||
raise TokenError("invalid_grant")
|
raise TokenError("invalid_grant")
|
||||||
|
|
||||||
|
def __post_init_refresh(self, raw_token: str, request: HttpRequest):
|
||||||
|
if not raw_token:
|
||||||
|
LOGGER.warning("Missing refresh token")
|
||||||
|
raise TokenError("invalid_grant")
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.refresh_token = RefreshToken.objects.get(
|
||||||
|
refresh_token=raw_token, provider=self.provider
|
||||||
|
)
|
||||||
|
if self.refresh_token.is_expired:
|
||||||
|
LOGGER.warning(
|
||||||
|
"Refresh token is expired",
|
||||||
|
token=raw_token,
|
||||||
|
)
|
||||||
|
raise TokenError("invalid_grant")
|
||||||
|
# https://tools.ietf.org/html/rfc6749#section-6
|
||||||
|
# Fallback to original token's scopes when none are given
|
||||||
|
if not self.scope:
|
||||||
|
self.scope = self.refresh_token.scope
|
||||||
|
except RefreshToken.DoesNotExist:
|
||||||
|
LOGGER.warning(
|
||||||
|
"Refresh token does not exist",
|
||||||
|
token=raw_token,
|
||||||
|
)
|
||||||
|
raise TokenError("invalid_grant")
|
||||||
|
if self.refresh_token.revoked:
|
||||||
|
LOGGER.warning("Refresh token is revoked", token=raw_token)
|
||||||
|
Event.new(
|
||||||
|
action=EventAction.SUSPICIOUS_REQUEST,
|
||||||
|
message="Revoked refresh token was used",
|
||||||
|
token=raw_token,
|
||||||
|
).from_http(request)
|
||||||
|
raise TokenError("invalid_grant")
|
||||||
|
|
||||||
|
def __post_init_client_credentials(self, request: HttpRequest):
|
||||||
|
# Authenticate user based on credentials
|
||||||
|
username = request.POST.get("username")
|
||||||
|
password = request.POST.get("password")
|
||||||
|
user = User.objects.filter(username=username).first()
|
||||||
|
if not user:
|
||||||
|
raise TokenError("invalid_grant")
|
||||||
|
token: Token = Token.filter_not_expired(
|
||||||
|
key=password, intent=TokenIntents.INTENT_APP_PASSWORD
|
||||||
|
).first()
|
||||||
|
if not token or token.user.uid != user.uid:
|
||||||
|
raise TokenError("invalid_grant")
|
||||||
|
self.user = user
|
||||||
|
if not self.user.attributes.get(USER_ATTRIBUTE_SA, False):
|
||||||
|
# Non-service accounts are not allowed
|
||||||
|
LOGGER.info("Non-service-account tried to use client credentials", user=self.user)
|
||||||
|
raise TokenError("invalid_grant")
|
||||||
|
|
||||||
|
Event.new(
|
||||||
|
action=EventAction.LOGIN,
|
||||||
|
PLAN_CONTEXT_METHOD="token",
|
||||||
|
PLAN_CONTEXT_METHOD_ARGS={
|
||||||
|
"identifier": token.identifier,
|
||||||
|
},
|
||||||
|
).from_http(request, user=user)
|
||||||
|
|
||||||
|
# Authorize user access
|
||||||
|
app = Application.objects.filter(provider=self.provider).first()
|
||||||
|
if not app or not app.provider:
|
||||||
|
raise TokenError("invalid_grant")
|
||||||
|
engine = PolicyEngine(app, self.user, request)
|
||||||
|
engine.build()
|
||||||
|
result = engine.result
|
||||||
|
if not result.passing:
|
||||||
|
LOGGER.info("User not authenticated for application", user=self.user, app=app)
|
||||||
|
raise TokenError("invalid_grant")
|
||||||
|
|
||||||
|
|
||||||
class TokenView(View):
|
class TokenView(View):
|
||||||
"""Generate tokens for clients"""
|
"""Generate tokens for clients"""
|
||||||
@ -206,11 +256,14 @@ class TokenView(View):
|
|||||||
self.params = TokenParams.parse(request, self.provider, client_id, client_secret)
|
self.params = TokenParams.parse(request, self.provider, client_id, client_secret)
|
||||||
|
|
||||||
if self.params.grant_type == GRANT_TYPE_AUTHORIZATION_CODE:
|
if self.params.grant_type == GRANT_TYPE_AUTHORIZATION_CODE:
|
||||||
LOGGER.info("Converting authorization code to refresh token")
|
LOGGER.debug("Converting authorization code to refresh token")
|
||||||
return TokenResponse(self.create_code_response())
|
return TokenResponse(self.create_code_response())
|
||||||
if self.params.grant_type == GRANT_TYPE_REFRESH_TOKEN:
|
if self.params.grant_type == GRANT_TYPE_REFRESH_TOKEN:
|
||||||
LOGGER.info("Refreshing refresh token")
|
LOGGER.debug("Refreshing refresh token")
|
||||||
return TokenResponse(self.create_refresh_response())
|
return TokenResponse(self.create_refresh_response())
|
||||||
|
if self.params.grant_type == GRANT_TYPE_CLIENT_CREDENTIALS:
|
||||||
|
LOGGER.debug("Client credentials grant")
|
||||||
|
return TokenResponse(self.create_client_credentials_response())
|
||||||
raise ValueError(f"Invalid grant_type: {self.params.grant_type}")
|
raise ValueError(f"Invalid grant_type: {self.params.grant_type}")
|
||||||
except TokenError as error:
|
except TokenError as error:
|
||||||
return TokenResponse(error.create_dict(), status=400)
|
return TokenResponse(error.create_dict(), status=400)
|
||||||
@ -290,3 +343,30 @@ class TokenView(View):
|
|||||||
),
|
),
|
||||||
"id_token": self.params.provider.encode(refresh_token.id_token.to_dict()),
|
"id_token": self.params.provider.encode(refresh_token.id_token.to_dict()),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def create_client_credentials_response(self) -> dict[str, Any]:
|
||||||
|
"""See https://datatracker.ietf.org/doc/html/rfc6749#section-4.4"""
|
||||||
|
provider: OAuth2Provider = self.params.provider
|
||||||
|
|
||||||
|
refresh_token: RefreshToken = provider.create_refresh_token(
|
||||||
|
user=self.params.user,
|
||||||
|
scope=self.params.scope,
|
||||||
|
request=self.request,
|
||||||
|
)
|
||||||
|
refresh_token.id_token = refresh_token.create_id_token(
|
||||||
|
user=self.params.user,
|
||||||
|
request=self.request,
|
||||||
|
)
|
||||||
|
refresh_token.id_token.at_hash = refresh_token.at_hash
|
||||||
|
|
||||||
|
# Store the refresh_token.
|
||||||
|
refresh_token.save()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"access_token": refresh_token.access_token,
|
||||||
|
"token_type": "bearer",
|
||||||
|
"expires_in": int(
|
||||||
|
timedelta_from_string(refresh_token.provider.token_validity).total_seconds()
|
||||||
|
),
|
||||||
|
"id_token": self.params.provider.encode(refresh_token.id_token.to_dict()),
|
||||||
|
}
|
||||||
|
|||||||
@ -12,4 +12,8 @@ class AuthentikProviderProxyConfig(AppConfig):
|
|||||||
verbose_name = "authentik Providers.Proxy"
|
verbose_name = "authentik Providers.Proxy"
|
||||||
|
|
||||||
def ready(self) -> None:
|
def ready(self) -> None:
|
||||||
|
from authentik.providers.proxy.tasks import proxy_set_defaults
|
||||||
|
|
||||||
import_module("authentik.providers.proxy.managed")
|
import_module("authentik.providers.proxy.managed")
|
||||||
|
|
||||||
|
proxy_set_defaults.delay()
|
||||||
|
|||||||
@ -23,15 +23,17 @@ class ProxyDockerController(DockerController):
|
|||||||
proxy_provider: ProxyProvider
|
proxy_provider: ProxyProvider
|
||||||
external_host_name = urlparse(proxy_provider.external_host)
|
external_host_name = urlparse(proxy_provider.external_host)
|
||||||
hosts.append(f"`{external_host_name.netloc}`")
|
hosts.append(f"`{external_host_name.netloc}`")
|
||||||
traefik_name = f"ak-outpost-{self.outpost.pk.hex}"
|
traefik_name = self.name
|
||||||
labels = super()._get_labels()
|
labels = super()._get_labels()
|
||||||
labels["traefik.enable"] = "true"
|
labels["traefik.enable"] = "true"
|
||||||
labels[f"traefik.http.routers.{traefik_name}-router.rule"] = f"Host({','.join(hosts)})"
|
labels[
|
||||||
|
f"traefik.http.routers.{traefik_name}-router.rule"
|
||||||
|
] = f"Host({','.join(hosts)}) && PathPrefix(`/outpost.goauthentik.io`)"
|
||||||
labels[f"traefik.http.routers.{traefik_name}-router.tls"] = "true"
|
labels[f"traefik.http.routers.{traefik_name}-router.tls"] = "true"
|
||||||
labels[f"traefik.http.routers.{traefik_name}-router.service"] = f"{traefik_name}-service"
|
labels[f"traefik.http.routers.{traefik_name}-router.service"] = f"{traefik_name}-service"
|
||||||
labels[
|
labels[
|
||||||
f"traefik.http.services.{traefik_name}-service.loadbalancer.healthcheck.path"
|
f"traefik.http.services.{traefik_name}-service.loadbalancer.healthcheck.path"
|
||||||
] = "/akprox/ping"
|
] = "/outpost.goauthentik.io/ping"
|
||||||
labels[
|
labels[
|
||||||
f"traefik.http.services.{traefik_name}-service.loadbalancer.healthcheck.port"
|
f"traefik.http.services.{traefik_name}-service.loadbalancer.healthcheck.port"
|
||||||
] = "9300"
|
] = "9300"
|
||||||
|
|||||||
@ -92,6 +92,8 @@ class IngressReconciler(KubernetesObjectReconciler[V1Ingress]):
|
|||||||
# Buffer sizes for large headers with JWTs
|
# Buffer sizes for large headers with JWTs
|
||||||
"nginx.ingress.kubernetes.io/proxy-buffers-number": "4",
|
"nginx.ingress.kubernetes.io/proxy-buffers-number": "4",
|
||||||
"nginx.ingress.kubernetes.io/proxy-buffer-size": "16k",
|
"nginx.ingress.kubernetes.io/proxy-buffer-size": "16k",
|
||||||
|
# Enable TLS in traefik
|
||||||
|
"traefik.ingress.kubernetes.io/router.tls": "true",
|
||||||
}
|
}
|
||||||
annotations.update(self.controller.outpost.config.kubernetes_ingress_annotations)
|
annotations.update(self.controller.outpost.config.kubernetes_ingress_annotations)
|
||||||
return annotations
|
return annotations
|
||||||
@ -126,7 +128,7 @@ class IngressReconciler(KubernetesObjectReconciler[V1Ingress]):
|
|||||||
port=V1ServiceBackendPort(name="http"),
|
port=V1ServiceBackendPort(name="http"),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
path="/akprox",
|
path="/outpost.goauthentik.io",
|
||||||
path_type="ImplementationSpecific",
|
path_type="ImplementationSpecific",
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
|
|||||||
@ -119,15 +119,11 @@ class TraefikMiddlewareReconciler(KubernetesObjectReconciler[TraefikMiddleware])
|
|||||||
),
|
),
|
||||||
spec=TraefikMiddlewareSpec(
|
spec=TraefikMiddlewareSpec(
|
||||||
forwardAuth=TraefikMiddlewareSpecForwardAuth(
|
forwardAuth=TraefikMiddlewareSpecForwardAuth(
|
||||||
address=f"http://{self.name}.{self.namespace}:9000/akprox/auth/traefik",
|
address=(
|
||||||
|
f"http://{self.name}.{self.namespace}:9000/"
|
||||||
|
"outpost.goauthentik.io/auth/traefik"
|
||||||
|
),
|
||||||
authResponseHeaders=[
|
authResponseHeaders=[
|
||||||
# Legacy headers, remove after 2022.1
|
|
||||||
"X-Auth-Username",
|
|
||||||
"X-Auth-Groups",
|
|
||||||
"X-Forwarded-Email",
|
|
||||||
"X-Forwarded-Preferred-Username",
|
|
||||||
"X-Forwarded-User",
|
|
||||||
# New headers, unique prefix
|
|
||||||
"X-authentik-username",
|
"X-authentik-username",
|
||||||
"X-authentik-groups",
|
"X-authentik-groups",
|
||||||
"X-authentik-email",
|
"X-authentik-email",
|
||||||
|
|||||||
@ -27,7 +27,7 @@ def get_cookie_secret():
|
|||||||
|
|
||||||
|
|
||||||
def _get_callback_url(uri: str) -> str:
|
def _get_callback_url(uri: str) -> str:
|
||||||
return urljoin(uri, "/akprox/callback")
|
return urljoin(uri, "outpost.goauthentik.io/callback")
|
||||||
|
|
||||||
|
|
||||||
class ProxyMode(models.TextChoices):
|
class ProxyMode(models.TextChoices):
|
||||||
|
|||||||
11
authentik/providers/proxy/tasks.py
Normal file
11
authentik/providers/proxy/tasks.py
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
"""proxy provider tasks"""
|
||||||
|
from authentik.providers.proxy.models import ProxyProvider
|
||||||
|
from authentik.root.celery import CELERY_APP
|
||||||
|
|
||||||
|
|
||||||
|
@CELERY_APP.task()
|
||||||
|
def proxy_set_defaults():
|
||||||
|
"""Ensure correct defaults are set for all providers"""
|
||||||
|
for provider in ProxyProvider.objects.all():
|
||||||
|
provider.set_oauth_defaults()
|
||||||
|
provider.save()
|
||||||
@ -15,6 +15,7 @@ from authentik.providers.saml.processors.request_parser import AuthNRequestParse
|
|||||||
from authentik.sources.saml.exceptions import MismatchedRequestID
|
from authentik.sources.saml.exceptions import MismatchedRequestID
|
||||||
from authentik.sources.saml.models import SAMLSource
|
from authentik.sources.saml.models import SAMLSource
|
||||||
from authentik.sources.saml.processors.constants import (
|
from authentik.sources.saml.processors.constants import (
|
||||||
|
SAML_BINDING_REDIRECT,
|
||||||
SAML_NAME_ID_FORMAT_EMAIL,
|
SAML_NAME_ID_FORMAT_EMAIL,
|
||||||
SAML_NAME_ID_FORMAT_UNSPECIFIED,
|
SAML_NAME_ID_FORMAT_UNSPECIFIED,
|
||||||
)
|
)
|
||||||
@ -98,6 +99,9 @@ class TestAuthNRequest(TestCase):
|
|||||||
|
|
||||||
# First create an AuthNRequest
|
# First create an AuthNRequest
|
||||||
request_proc = RequestProcessor(self.source, http_request, "test_state")
|
request_proc = RequestProcessor(self.source, http_request, "test_state")
|
||||||
|
auth_n = request_proc.get_auth_n()
|
||||||
|
self.assertEqual(auth_n.attrib["ProtocolBinding"], SAML_BINDING_REDIRECT)
|
||||||
|
|
||||||
request = request_proc.build_auth_n()
|
request = request_proc.build_auth_n()
|
||||||
# Now we check the ID and signature
|
# Now we check the ID and signature
|
||||||
parsed_request = AuthNRequestParser(self.provider).parse(
|
parsed_request = AuthNRequestParser(self.provider).parse(
|
||||||
|
|||||||
@ -1,37 +1,17 @@
|
|||||||
"""Metrics view"""
|
"""Metrics view"""
|
||||||
from base64 import b64encode
|
from base64 import b64encode
|
||||||
from typing import Callable
|
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.db import connections
|
from django.db import connections
|
||||||
from django.db.utils import OperationalError
|
from django.db.utils import OperationalError
|
||||||
|
from django.dispatch import Signal
|
||||||
from django.http import HttpRequest, HttpResponse
|
from django.http import HttpRequest, HttpResponse
|
||||||
from django.views import View
|
from django.views import View
|
||||||
from django_prometheus.exports import ExportToDjangoView
|
from django_prometheus.exports import ExportToDjangoView
|
||||||
from django_redis import get_redis_connection
|
from django_redis import get_redis_connection
|
||||||
from prometheus_client import Gauge
|
|
||||||
from redis.exceptions import RedisError
|
from redis.exceptions import RedisError
|
||||||
|
|
||||||
from authentik.admin.api.workers import GAUGE_WORKERS
|
monitoring_set = Signal()
|
||||||
from authentik.events.monitored_tasks import TaskInfo
|
|
||||||
from authentik.root.celery import CELERY_APP
|
|
||||||
|
|
||||||
|
|
||||||
class UpdatingGauge(Gauge):
|
|
||||||
"""Gauge which fetches its own value from an update function.
|
|
||||||
|
|
||||||
Update function is called on instantiate"""
|
|
||||||
|
|
||||||
def __init__(self, *args, update_func: Callable, **kwargs):
|
|
||||||
super().__init__(*args, **kwargs)
|
|
||||||
self._update_func = update_func
|
|
||||||
self.update()
|
|
||||||
|
|
||||||
def update(self):
|
|
||||||
"""Set value from update function"""
|
|
||||||
val = self._update_func()
|
|
||||||
if val:
|
|
||||||
self.set(val)
|
|
||||||
|
|
||||||
|
|
||||||
class MetricsView(View):
|
class MetricsView(View):
|
||||||
@ -49,11 +29,7 @@ class MetricsView(View):
|
|||||||
response["WWW-Authenticate"] = 'Basic realm="authentik-monitoring"'
|
response["WWW-Authenticate"] = 'Basic realm="authentik-monitoring"'
|
||||||
return response
|
return response
|
||||||
|
|
||||||
count = len(CELERY_APP.control.ping(timeout=0.5))
|
monitoring_set.send_robust(self)
|
||||||
GAUGE_WORKERS.set(count)
|
|
||||||
|
|
||||||
for task in TaskInfo.all().values():
|
|
||||||
task.set_prom_metrics()
|
|
||||||
|
|
||||||
return ExportToDjangoView(request)
|
return ExportToDjangoView(request)
|
||||||
|
|
||||||
|
|||||||
@ -1,14 +1,4 @@
|
|||||||
"""
|
"""root settings for authentik"""
|
||||||
Django settings for authentik project.
|
|
||||||
|
|
||||||
Generated by 'django-admin startproject' using Django 2.1.3.
|
|
||||||
|
|
||||||
For more information on this file, see
|
|
||||||
https://docs.djangoproject.com/en/2.1/topics/settings/
|
|
||||||
|
|
||||||
For the full list of settings and their values, see
|
|
||||||
https://docs.djangoproject.com/en/2.1/ref/settings/
|
|
||||||
"""
|
|
||||||
|
|
||||||
import importlib
|
import importlib
|
||||||
import logging
|
import logging
|
||||||
@ -16,26 +6,23 @@ import os
|
|||||||
import sys
|
import sys
|
||||||
from hashlib import sha512
|
from hashlib import sha512
|
||||||
from json import dumps
|
from json import dumps
|
||||||
from tempfile import gettempdir
|
|
||||||
from time import time
|
from time import time
|
||||||
from urllib.parse import quote
|
from urllib.parse import quote_plus
|
||||||
|
|
||||||
import structlog
|
import structlog
|
||||||
from celery.schedules import crontab
|
from celery.schedules import crontab
|
||||||
from sentry_sdk import init as sentry_init
|
from sentry_sdk import init as sentry_init
|
||||||
from sentry_sdk.api import set_tag
|
from sentry_sdk.api import set_tag
|
||||||
from sentry_sdk.integrations.boto3 import Boto3Integration
|
|
||||||
from sentry_sdk.integrations.celery import CeleryIntegration
|
from sentry_sdk.integrations.celery import CeleryIntegration
|
||||||
from sentry_sdk.integrations.django import DjangoIntegration
|
from sentry_sdk.integrations.django import DjangoIntegration
|
||||||
from sentry_sdk.integrations.redis import RedisIntegration
|
from sentry_sdk.integrations.redis import RedisIntegration
|
||||||
from sentry_sdk.integrations.threading import ThreadingIntegration
|
from sentry_sdk.integrations.threading import ThreadingIntegration
|
||||||
|
|
||||||
from authentik import ENV_GIT_HASH_KEY, __version__
|
from authentik import ENV_GIT_HASH_KEY, __version__, get_build_hash
|
||||||
from authentik.core.middleware import structlog_add_request_id
|
from authentik.core.middleware import structlog_add_request_id
|
||||||
from authentik.lib.config import CONFIG
|
from authentik.lib.config import CONFIG
|
||||||
from authentik.lib.logging import add_process_id
|
from authentik.lib.logging import add_process_id
|
||||||
from authentik.lib.sentry import before_send
|
from authentik.lib.sentry import before_send
|
||||||
from authentik.lib.utils.http import get_http_session
|
|
||||||
from authentik.lib.utils.reflection import get_env
|
from authentik.lib.utils.reflection import get_env
|
||||||
from authentik.stages.password import BACKEND_APP_PASSWORD, BACKEND_INBUILT, BACKEND_LDAP
|
from authentik.stages.password import BACKEND_APP_PASSWORD, BACKEND_INBUILT, BACKEND_LDAP
|
||||||
|
|
||||||
@ -75,6 +62,7 @@ AUTH_USER_MODEL = "authentik_core.User"
|
|||||||
|
|
||||||
_cookie_suffix = "_debug" if DEBUG else ""
|
_cookie_suffix = "_debug" if DEBUG else ""
|
||||||
CSRF_COOKIE_NAME = "authentik_csrf"
|
CSRF_COOKIE_NAME = "authentik_csrf"
|
||||||
|
CSRF_HEADER_NAME = "HTTP_X_AUTHENTIK_CSRF"
|
||||||
LANGUAGE_COOKIE_NAME = f"authentik_language{_cookie_suffix}"
|
LANGUAGE_COOKIE_NAME = f"authentik_language{_cookie_suffix}"
|
||||||
SESSION_COOKIE_NAME = f"authentik_session{_cookie_suffix}"
|
SESSION_COOKIE_NAME = f"authentik_session{_cookie_suffix}"
|
||||||
SESSION_COOKIE_DOMAIN = CONFIG.y("cookie_domain", None)
|
SESSION_COOKIE_DOMAIN = CONFIG.y("cookie_domain", None)
|
||||||
@ -148,7 +136,6 @@ INSTALLED_APPS = [
|
|||||||
"guardian",
|
"guardian",
|
||||||
"django_prometheus",
|
"django_prometheus",
|
||||||
"channels",
|
"channels",
|
||||||
"dbbackup",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
GUARDIAN_MONKEY_PATCH = False
|
GUARDIAN_MONKEY_PATCH = False
|
||||||
@ -164,9 +151,6 @@ SPECTACULAR_SETTINGS = {
|
|||||||
{
|
{
|
||||||
"url": "/api/v3/",
|
"url": "/api/v3/",
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"url": "/api/v2beta/",
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
"CONTACT": {
|
"CONTACT": {
|
||||||
"email": "hello@beryju.org",
|
"email": "hello@beryju.org",
|
||||||
@ -222,7 +206,7 @@ if CONFIG.y_bool("redis.tls", False):
|
|||||||
REDIS_CELERY_TLS_REQUIREMENTS = f"?ssl_cert_reqs={CONFIG.y('redis.tls_reqs')}"
|
REDIS_CELERY_TLS_REQUIREMENTS = f"?ssl_cert_reqs={CONFIG.y('redis.tls_reqs')}"
|
||||||
_redis_url = (
|
_redis_url = (
|
||||||
f"{REDIS_PROTOCOL_PREFIX}:"
|
f"{REDIS_PROTOCOL_PREFIX}:"
|
||||||
f"{quote(CONFIG.y('redis.password'))}@{quote(CONFIG.y('redis.host'))}:"
|
f"{quote_plus(CONFIG.y('redis.password'))}@{quote_plus(CONFIG.y('redis.host'))}:"
|
||||||
f"{int(CONFIG.y('redis.port'))}"
|
f"{int(CONFIG.y('redis.port'))}"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -349,6 +333,7 @@ LOCALE_PATHS = ["./locale"]
|
|||||||
# Celery settings
|
# Celery settings
|
||||||
# Add a 10 minute timeout to all Celery tasks.
|
# Add a 10 minute timeout to all Celery tasks.
|
||||||
CELERY_TASK_SOFT_TIME_LIMIT = 600
|
CELERY_TASK_SOFT_TIME_LIMIT = 600
|
||||||
|
CELERY_WORKER_MAX_TASKS_PER_CHILD = 50
|
||||||
CELERY_BEAT_SCHEDULE = {
|
CELERY_BEAT_SCHEDULE = {
|
||||||
"clean_expired_models": {
|
"clean_expired_models": {
|
||||||
"task": "authentik.core.tasks.clean_expired_models",
|
"task": "authentik.core.tasks.clean_expired_models",
|
||||||
@ -357,7 +342,7 @@ CELERY_BEAT_SCHEDULE = {
|
|||||||
},
|
},
|
||||||
"db_backup": {
|
"db_backup": {
|
||||||
"task": "authentik.core.tasks.backup_database",
|
"task": "authentik.core.tasks.backup_database",
|
||||||
"schedule": crontab(hour="*/24"),
|
"schedule": crontab(hour="*/24", minute=0),
|
||||||
"options": {"queue": "authentik_scheduled"},
|
"options": {"queue": "authentik_scheduled"},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@ -370,38 +355,8 @@ CELERY_RESULT_BACKEND = (
|
|||||||
f"{_redis_url}/{CONFIG.y('redis.message_queue_db')}{REDIS_CELERY_TLS_REQUIREMENTS}"
|
f"{_redis_url}/{CONFIG.y('redis.message_queue_db')}{REDIS_CELERY_TLS_REQUIREMENTS}"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Database backup
|
|
||||||
DBBACKUP_STORAGE = "django.core.files.storage.FileSystemStorage"
|
|
||||||
DBBACKUP_STORAGE_OPTIONS = {"location": "./backups" if DEBUG else "/backups"}
|
|
||||||
DBBACKUP_FILENAME_TEMPLATE = f"authentik-backup-{__version__}-{{datetime}}.sql"
|
|
||||||
DBBACKUP_CONNECTOR_MAPPING = {
|
|
||||||
"django_prometheus.db.backends.postgresql": "dbbackup.db.postgresql.PgDumpConnector",
|
|
||||||
}
|
|
||||||
DBBACKUP_TMP_DIR = gettempdir() if DEBUG else "/tmp" # nosec
|
|
||||||
DBBACKUP_CLEANUP_KEEP = 30
|
|
||||||
if CONFIG.y("postgresql.s3_backup.bucket", "") != "":
|
|
||||||
DBBACKUP_STORAGE = "storages.backends.s3boto3.S3Boto3Storage"
|
|
||||||
DBBACKUP_STORAGE_OPTIONS = {
|
|
||||||
"access_key": CONFIG.y("postgresql.s3_backup.access_key"),
|
|
||||||
"secret_key": CONFIG.y("postgresql.s3_backup.secret_key"),
|
|
||||||
"bucket_name": CONFIG.y("postgresql.s3_backup.bucket"),
|
|
||||||
"region_name": CONFIG.y("postgresql.s3_backup.region", "eu-central-1"),
|
|
||||||
"default_acl": "private",
|
|
||||||
"endpoint_url": CONFIG.y("postgresql.s3_backup.host"),
|
|
||||||
"location": CONFIG.y("postgresql.s3_backup.location", ""),
|
|
||||||
"verify": not CONFIG.y_bool("postgresql.s3_backup.insecure_skip_verify", False),
|
|
||||||
}
|
|
||||||
j_print(
|
|
||||||
"Database backup to S3 is configured",
|
|
||||||
host=CONFIG.y("postgresql.s3_backup.host"),
|
|
||||||
)
|
|
||||||
|
|
||||||
# Sentry integration
|
# Sentry integration
|
||||||
SENTRY_DSN = "https://a579bb09306d4f8b8d8847c052d3a1d3@sentry.beryju.org/8"
|
SENTRY_DSN = "https://a579bb09306d4f8b8d8847c052d3a1d3@sentry.beryju.org/8"
|
||||||
# Default to empty string as that is what docker has
|
|
||||||
build_hash = os.environ.get(ENV_GIT_HASH_KEY, "")
|
|
||||||
if build_hash == "":
|
|
||||||
build_hash = "tagged"
|
|
||||||
|
|
||||||
env = get_env()
|
env = get_env()
|
||||||
_ERROR_REPORTING = CONFIG.y_bool("error_reporting.enabled", False)
|
_ERROR_REPORTING = CONFIG.y_bool("error_reporting.enabled", False)
|
||||||
@ -413,7 +368,6 @@ if _ERROR_REPORTING:
|
|||||||
DjangoIntegration(transaction_style="function_name"),
|
DjangoIntegration(transaction_style="function_name"),
|
||||||
CeleryIntegration(),
|
CeleryIntegration(),
|
||||||
RedisIntegration(),
|
RedisIntegration(),
|
||||||
Boto3Integration(),
|
|
||||||
ThreadingIntegration(propagate_hub=True),
|
ThreadingIntegration(propagate_hub=True),
|
||||||
],
|
],
|
||||||
before_send=before_send,
|
before_send=before_send,
|
||||||
@ -422,35 +376,14 @@ if _ERROR_REPORTING:
|
|||||||
environment=CONFIG.y("error_reporting.environment", "customer"),
|
environment=CONFIG.y("error_reporting.environment", "customer"),
|
||||||
send_default_pii=CONFIG.y_bool("error_reporting.send_pii", False),
|
send_default_pii=CONFIG.y_bool("error_reporting.send_pii", False),
|
||||||
)
|
)
|
||||||
set_tag("authentik.build_hash", build_hash)
|
set_tag("authentik.build_hash", get_build_hash("tagged"))
|
||||||
set_tag("authentik.env", env)
|
set_tag("authentik.env", env)
|
||||||
set_tag("authentik.component", "backend")
|
set_tag("authentik.component", "backend")
|
||||||
set_tag("authentik.uuid", sha512(SECRET_KEY.encode("ascii")).hexdigest()[:16])
|
set_tag("authentik.uuid", sha512(str(SECRET_KEY).encode("ascii")).hexdigest()[:16])
|
||||||
j_print(
|
j_print(
|
||||||
"Error reporting is enabled",
|
"Error reporting is enabled",
|
||||||
env=CONFIG.y("error_reporting.environment", "customer"),
|
env=CONFIG.y("error_reporting.environment", "customer"),
|
||||||
)
|
)
|
||||||
if not CONFIG.y_bool("disable_startup_analytics", False):
|
|
||||||
should_send = env not in ["dev", "ci"]
|
|
||||||
if should_send:
|
|
||||||
try:
|
|
||||||
get_http_session().post(
|
|
||||||
"https://goauthentik.io/api/event",
|
|
||||||
json={
|
|
||||||
"domain": "authentik",
|
|
||||||
"name": "pageview",
|
|
||||||
"referrer": f"{__version__} ({build_hash})",
|
|
||||||
"url": f"http://localhost/{env}?utm_source={__version__}&utm_medium={env}",
|
|
||||||
},
|
|
||||||
headers={
|
|
||||||
"User-Agent": sha512(SECRET_KEY.encode("ascii")).hexdigest()[:16],
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
timeout=5,
|
|
||||||
)
|
|
||||||
# pylint: disable=bare-except
|
|
||||||
except: # nosec
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Static files (CSS, JavaScript, Images)
|
# Static files (CSS, JavaScript, Images)
|
||||||
# https://docs.djangoproject.com/en/2.1/howto/static-files/
|
# https://docs.djangoproject.com/en/2.1/howto/static-files/
|
||||||
@ -532,12 +465,9 @@ _LOGGING_HANDLER_MAP = {
|
|||||||
"urllib3": "WARNING",
|
"urllib3": "WARNING",
|
||||||
"websockets": "WARNING",
|
"websockets": "WARNING",
|
||||||
"daphne": "WARNING",
|
"daphne": "WARNING",
|
||||||
"dbbackup": "ERROR",
|
|
||||||
"kubernetes": "INFO",
|
"kubernetes": "INFO",
|
||||||
"asyncio": "WARNING",
|
"asyncio": "WARNING",
|
||||||
"aioredis": "WARNING",
|
"aioredis": "WARNING",
|
||||||
"s3transfer": "WARNING",
|
|
||||||
"botocore": "WARNING",
|
|
||||||
}
|
}
|
||||||
for handler_name, level in _LOGGING_HANDLER_MAP.items():
|
for handler_name, level in _LOGGING_HANDLER_MAP.items():
|
||||||
# pyright: reportGeneralTypeIssues=false
|
# pyright: reportGeneralTypeIssues=false
|
||||||
|
|||||||
@ -35,21 +35,21 @@ class LDAPProviderManager(ObjectManager):
|
|||||||
"goauthentik.io/sources/ldap/ms-userprincipalname",
|
"goauthentik.io/sources/ldap/ms-userprincipalname",
|
||||||
name="authentik default Active Directory Mapping: userPrincipalName",
|
name="authentik default Active Directory Mapping: userPrincipalName",
|
||||||
object_field="attributes.upn",
|
object_field="attributes.upn",
|
||||||
expression="return ldap.get('userPrincipalName')",
|
expression="return list_flatten(ldap.get('userPrincipalName'))",
|
||||||
),
|
),
|
||||||
EnsureExists(
|
EnsureExists(
|
||||||
LDAPPropertyMapping,
|
LDAPPropertyMapping,
|
||||||
"goauthentik.io/sources/ldap/ms-givenName",
|
"goauthentik.io/sources/ldap/ms-givenName",
|
||||||
name="authentik default Active Directory Mapping: givenName",
|
name="authentik default Active Directory Mapping: givenName",
|
||||||
object_field="attributes.givenName",
|
object_field="attributes.givenName",
|
||||||
expression="return ldap.get('givenName')",
|
expression="return list_flatten(ldap.get('givenName'))",
|
||||||
),
|
),
|
||||||
EnsureExists(
|
EnsureExists(
|
||||||
LDAPPropertyMapping,
|
LDAPPropertyMapping,
|
||||||
"goauthentik.io/sources/ldap/ms-sn",
|
"goauthentik.io/sources/ldap/ms-sn",
|
||||||
name="authentik default Active Directory Mapping: sn",
|
name="authentik default Active Directory Mapping: sn",
|
||||||
object_field="attributes.sn",
|
object_field="attributes.sn",
|
||||||
expression="return ldap.get('sn')",
|
expression="return list_flatten(ldap.get('sn'))",
|
||||||
),
|
),
|
||||||
# OpenLDAP specific mappings
|
# OpenLDAP specific mappings
|
||||||
EnsureExists(
|
EnsureExists(
|
||||||
|
|||||||
@ -1,13 +1,13 @@
|
|||||||
"""Sync LDAP Users and groups into authentik"""
|
"""Sync LDAP Users and groups into authentik"""
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from deepmerge import always_merger
|
|
||||||
from django.db.models.base import Model
|
from django.db.models.base import Model
|
||||||
from django.db.models.query import QuerySet
|
from django.db.models.query import QuerySet
|
||||||
from structlog.stdlib import BoundLogger, get_logger
|
from structlog.stdlib import BoundLogger, get_logger
|
||||||
|
|
||||||
from authentik.core.exceptions import PropertyMappingExpressionException
|
from authentik.core.exceptions import PropertyMappingExpressionException
|
||||||
from authentik.events.models import Event, EventAction
|
from authentik.events.models import Event, EventAction
|
||||||
|
from authentik.lib.merge import MERGE_LIST_UNIQUE
|
||||||
from authentik.sources.ldap.auth import LDAP_DISTINGUISHED_NAME
|
from authentik.sources.ldap.auth import LDAP_DISTINGUISHED_NAME
|
||||||
from authentik.sources.ldap.models import LDAPPropertyMapping, LDAPSource
|
from authentik.sources.ldap.models import LDAPPropertyMapping, LDAPSource
|
||||||
|
|
||||||
@ -123,8 +123,8 @@ class BaseLDAPSynchronizer:
|
|||||||
continue
|
continue
|
||||||
setattr(instance, key, value)
|
setattr(instance, key, value)
|
||||||
final_atttributes = {}
|
final_atttributes = {}
|
||||||
always_merger.merge(final_atttributes, instance.attributes)
|
MERGE_LIST_UNIQUE.merge(final_atttributes, instance.attributes)
|
||||||
always_merger.merge(final_atttributes, data.get("attributes", {}))
|
MERGE_LIST_UNIQUE.merge(final_atttributes, data.get("attributes", {}))
|
||||||
instance.attributes = final_atttributes
|
instance.attributes = final_atttributes
|
||||||
instance.save()
|
instance.save()
|
||||||
return (instance, False)
|
return (instance, False)
|
||||||
|
|||||||
@ -37,6 +37,7 @@ class GroupLDAPSynchronizer(BaseLDAPSynchronizer):
|
|||||||
uniq = self._flatten(attributes[self._source.object_uniqueness_field])
|
uniq = self._flatten(attributes[self._source.object_uniqueness_field])
|
||||||
try:
|
try:
|
||||||
defaults = self.build_group_properties(group_dn, **attributes)
|
defaults = self.build_group_properties(group_dn, **attributes)
|
||||||
|
defaults["parent"] = self._source.sync_parent_group
|
||||||
self._logger.debug("Creating group with attributes", **defaults)
|
self._logger.debug("Creating group with attributes", **defaults)
|
||||||
if "name" not in defaults:
|
if "name" not in defaults:
|
||||||
raise IntegrityError("Name was not set by propertymappings")
|
raise IntegrityError("Name was not set by propertymappings")
|
||||||
@ -47,7 +48,6 @@ class GroupLDAPSynchronizer(BaseLDAPSynchronizer):
|
|||||||
Group,
|
Group,
|
||||||
{
|
{
|
||||||
f"attributes__{LDAP_UNIQUENESS}": uniq,
|
f"attributes__{LDAP_UNIQUENESS}": uniq,
|
||||||
"parent": self._source.sync_parent_group,
|
|
||||||
},
|
},
|
||||||
defaults,
|
defaults,
|
||||||
)
|
)
|
||||||
|
|||||||
@ -3,6 +3,7 @@ from ldap3.core.exceptions import LDAPException
|
|||||||
from structlog.stdlib import get_logger
|
from structlog.stdlib import get_logger
|
||||||
|
|
||||||
from authentik.events.monitored_tasks import MonitoredTask, TaskResult, TaskResultStatus
|
from authentik.events.monitored_tasks import MonitoredTask, TaskResult, TaskResultStatus
|
||||||
|
from authentik.lib.utils.errors import exception_to_string
|
||||||
from authentik.lib.utils.reflection import class_to_path, path_to_class
|
from authentik.lib.utils.reflection import class_to_path, path_to_class
|
||||||
from authentik.root.celery import CELERY_APP
|
from authentik.root.celery import CELERY_APP
|
||||||
from authentik.sources.ldap.models import LDAPSource
|
from authentik.sources.ldap.models import LDAPSource
|
||||||
@ -52,5 +53,5 @@ def ldap_sync(self: MonitoredTask, source_pk: str, sync_class: str):
|
|||||||
)
|
)
|
||||||
except LDAPException as exc:
|
except LDAPException as exc:
|
||||||
# No explicit event is created here as .set_status with an error will do that
|
# No explicit event is created here as .set_status with an error will do that
|
||||||
LOGGER.debug(exc)
|
LOGGER.warning(exception_to_string(exc))
|
||||||
self.set_status(TaskResult(TaskResultStatus.ERROR).with_error(exc))
|
self.set_status(TaskResult(TaskResultStatus.ERROR).with_error(exc))
|
||||||
|
|||||||
@ -5,6 +5,7 @@ from django.db.models import Q
|
|||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
|
|
||||||
from authentik.core.models import Group, User
|
from authentik.core.models import Group, User
|
||||||
|
from authentik.core.tests.utils import create_test_admin_user
|
||||||
from authentik.events.models import Event, EventAction
|
from authentik.events.models import Event, EventAction
|
||||||
from authentik.lib.generators import generate_key
|
from authentik.lib.generators import generate_key
|
||||||
from authentik.managed.manager import ObjectManager
|
from authentik.managed.manager import ObjectManager
|
||||||
@ -24,7 +25,7 @@ class LDAPSyncTests(TestCase):
|
|||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
ObjectManager().run()
|
ObjectManager().run()
|
||||||
self.source = LDAPSource.objects.create(
|
self.source: LDAPSource = LDAPSource.objects.create(
|
||||||
name="ldap",
|
name="ldap",
|
||||||
slug="ldap",
|
slug="ldap",
|
||||||
base_dn="dc=goauthentik,dc=io",
|
base_dn="dc=goauthentik,dc=io",
|
||||||
@ -120,6 +121,9 @@ class LDAPSyncTests(TestCase):
|
|||||||
self.source.property_mappings_group.set(
|
self.source.property_mappings_group.set(
|
||||||
LDAPPropertyMapping.objects.filter(managed="goauthentik.io/sources/ldap/default-name")
|
LDAPPropertyMapping.objects.filter(managed="goauthentik.io/sources/ldap/default-name")
|
||||||
)
|
)
|
||||||
|
_user = create_test_admin_user()
|
||||||
|
parent_group = Group.objects.get(name=_user.username)
|
||||||
|
self.source.sync_parent_group = parent_group
|
||||||
connection = PropertyMock(return_value=mock_ad_connection(LDAP_PASSWORD))
|
connection = PropertyMock(return_value=mock_ad_connection(LDAP_PASSWORD))
|
||||||
with patch("authentik.sources.ldap.models.LDAPSource.connection", connection):
|
with patch("authentik.sources.ldap.models.LDAPSource.connection", connection):
|
||||||
self.source.save()
|
self.source.save()
|
||||||
@ -127,8 +131,9 @@ class LDAPSyncTests(TestCase):
|
|||||||
group_sync.sync()
|
group_sync.sync()
|
||||||
membership_sync = MembershipLDAPSynchronizer(self.source)
|
membership_sync = MembershipLDAPSynchronizer(self.source)
|
||||||
membership_sync.sync()
|
membership_sync.sync()
|
||||||
group = Group.objects.filter(name="test-group")
|
group: Group = Group.objects.filter(name="test-group").first()
|
||||||
self.assertTrue(group.exists())
|
self.assertIsNotNone(group)
|
||||||
|
self.assertEqual(group.parent, parent_group)
|
||||||
|
|
||||||
def test_sync_groups_openldap(self):
|
def test_sync_groups_openldap(self):
|
||||||
"""Test group sync"""
|
"""Test group sync"""
|
||||||
|
|||||||
@ -17,6 +17,7 @@ AUTHENTIK_SOURCES_OAUTH_TYPES = [
|
|||||||
"authentik.sources.oauth.types.okta",
|
"authentik.sources.oauth.types.okta",
|
||||||
"authentik.sources.oauth.types.reddit",
|
"authentik.sources.oauth.types.reddit",
|
||||||
"authentik.sources.oauth.types.twitter",
|
"authentik.sources.oauth.types.twitter",
|
||||||
|
"authentik.sources.oauth.types.mailcow",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -44,7 +44,7 @@ class BaseOAuthClient:
|
|||||||
response = self.do_request("get", profile_url, token=token)
|
response = self.do_request("get", profile_url, token=token)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
except RequestException as exc:
|
except RequestException as exc:
|
||||||
LOGGER.warning("Unable to fetch user profile", exc=exc)
|
LOGGER.warning("Unable to fetch user profile", exc=exc, body=response.text)
|
||||||
return None
|
return None
|
||||||
else:
|
else:
|
||||||
return response.json()
|
return response.json()
|
||||||
|
|||||||
@ -11,7 +11,7 @@ def update_empty_urls(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
|
|||||||
|
|
||||||
for source in OAuthSource.objects.using(db_alias).all():
|
for source in OAuthSource.objects.using(db_alias).all():
|
||||||
changed = False
|
changed = False
|
||||||
if source.access_token_url == "":
|
if source.access_token_url == "": # nosec
|
||||||
source.access_token_url = None
|
source.access_token_url = None
|
||||||
changed = True
|
changed = True
|
||||||
if source.authorization_url == "":
|
if source.authorization_url == "":
|
||||||
@ -20,7 +20,7 @@ def update_empty_urls(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
|
|||||||
if source.profile_url == "":
|
if source.profile_url == "":
|
||||||
source.profile_url = None
|
source.profile_url = None
|
||||||
changed = True
|
changed = True
|
||||||
if source.request_token_url == "":
|
if source.request_token_url == "": # nosec
|
||||||
source.request_token_url = None
|
source.request_token_url = None
|
||||||
changed = True
|
changed = True
|
||||||
|
|
||||||
|
|||||||
@ -111,6 +111,16 @@ class GitHubOAuthSource(OAuthSource):
|
|||||||
verbose_name_plural = _("GitHub OAuth Sources")
|
verbose_name_plural = _("GitHub OAuth Sources")
|
||||||
|
|
||||||
|
|
||||||
|
class MailcowOAuthSource(OAuthSource):
|
||||||
|
"""Social Login using Mailcow."""
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
|
||||||
|
abstract = True
|
||||||
|
verbose_name = _("Mailcow OAuth Source")
|
||||||
|
verbose_name_plural = _("Mailcow OAuth Sources")
|
||||||
|
|
||||||
|
|
||||||
class TwitterOAuthSource(OAuthSource):
|
class TwitterOAuthSource(OAuthSource):
|
||||||
"""Social Login using Twitter.com"""
|
"""Social Login using Twitter.com"""
|
||||||
|
|
||||||
|
|||||||
38
authentik/sources/oauth/tests/test_type_mailcow.py
Normal file
38
authentik/sources/oauth/tests/test_type_mailcow.py
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
"""Mailcow Type tests"""
|
||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
|
from authentik.sources.oauth.models import OAuthSource
|
||||||
|
from authentik.sources.oauth.types.mailcow import MailcowOAuth2Callback
|
||||||
|
|
||||||
|
# https://community.mailcow.email/d/13-mailcow-oauth-json-format/2
|
||||||
|
MAILCOW_USER = {
|
||||||
|
"success": True,
|
||||||
|
"username": "email@example.com",
|
||||||
|
"identifier": "email@example.com",
|
||||||
|
"email": "email@example.com",
|
||||||
|
"full_name": "Example User",
|
||||||
|
"displayName": "Example User",
|
||||||
|
"created": "2020-05-15 11:33:08",
|
||||||
|
"modified": "2020-05-15 12:23:31",
|
||||||
|
"active": 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class TestTypeMailcow(TestCase):
|
||||||
|
"""OAuth Source tests"""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.source = OAuthSource.objects.create(
|
||||||
|
name="test",
|
||||||
|
slug="test",
|
||||||
|
provider_type="mailcow",
|
||||||
|
authorization_url="",
|
||||||
|
profile_url="",
|
||||||
|
consumer_key="",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_enroll_context(self):
|
||||||
|
"""Test mailcow Enrollment context"""
|
||||||
|
ak_context = MailcowOAuth2Callback().get_user_enroll_context(MAILCOW_USER)
|
||||||
|
self.assertEqual(ak_context["email"], MAILCOW_USER["email"])
|
||||||
|
self.assertEqual(ak_context["name"], MAILCOW_USER["full_name"])
|
||||||
@ -37,7 +37,7 @@ class AzureADClient(OAuth2Client):
|
|||||||
)
|
)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
except RequestException as exc:
|
except RequestException as exc:
|
||||||
LOGGER.warning("Unable to fetch user profile", exc=exc)
|
LOGGER.warning("Unable to fetch user profile", exc=exc, body=response.text)
|
||||||
return None
|
return None
|
||||||
else:
|
else:
|
||||||
return response.json()
|
return response.json()
|
||||||
|
|||||||
69
authentik/sources/oauth/types/mailcow.py
Normal file
69
authentik/sources/oauth/types/mailcow.py
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
"""Mailcow OAuth Views"""
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
from requests.exceptions import RequestException
|
||||||
|
from structlog.stdlib import get_logger
|
||||||
|
|
||||||
|
from authentik.sources.oauth.clients.oauth2 import OAuth2Client
|
||||||
|
from authentik.sources.oauth.types.manager import MANAGER, SourceType
|
||||||
|
from authentik.sources.oauth.views.callback import OAuthCallback
|
||||||
|
from authentik.sources.oauth.views.redirect import OAuthRedirect
|
||||||
|
|
||||||
|
LOGGER = get_logger()
|
||||||
|
|
||||||
|
|
||||||
|
class MailcowOAuthRedirect(OAuthRedirect):
|
||||||
|
"""Mailcow OAuth2 Redirect"""
|
||||||
|
|
||||||
|
def get_additional_parameters(self, source): # pragma: no cover
|
||||||
|
return {
|
||||||
|
"scope": ["profile"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class MailcowOAuth2Client(OAuth2Client):
|
||||||
|
"""MailcowOAuth2Client, for some reason, mailcow does not like the default headers"""
|
||||||
|
|
||||||
|
def get_profile_info(self, token: dict[str, str]) -> Optional[dict[str, Any]]:
|
||||||
|
"Fetch user profile information."
|
||||||
|
profile_url = self.source.type.profile_url or ""
|
||||||
|
if self.source.type.urls_customizable and self.source.profile_url:
|
||||||
|
profile_url = self.source.profile_url
|
||||||
|
try:
|
||||||
|
response = self.session.request(
|
||||||
|
"get",
|
||||||
|
f"{profile_url}?access_token={token['access_token']}",
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
except RequestException as exc:
|
||||||
|
LOGGER.warning("Unable to fetch user profile", exc=exc, body=response.text)
|
||||||
|
return None
|
||||||
|
else:
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
|
||||||
|
class MailcowOAuth2Callback(OAuthCallback):
|
||||||
|
"""Mailcow OAuth2 Callback"""
|
||||||
|
|
||||||
|
client_class = MailcowOAuth2Client
|
||||||
|
|
||||||
|
def get_user_enroll_context(
|
||||||
|
self,
|
||||||
|
info: dict[str, Any],
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"email": info.get("email"),
|
||||||
|
"name": info.get("full_name"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@MANAGER.type()
|
||||||
|
class MailcowType(SourceType):
|
||||||
|
"""Mailcow Type definition"""
|
||||||
|
|
||||||
|
callback_view = MailcowOAuth2Callback
|
||||||
|
redirect_view = MailcowOAuthRedirect
|
||||||
|
name = "Mailcow"
|
||||||
|
slug = "mailcow"
|
||||||
|
|
||||||
|
urls_customizable = True
|
||||||
@ -18,6 +18,8 @@ from authentik.sources.saml.processors.constants import (
|
|||||||
RSA_SHA256,
|
RSA_SHA256,
|
||||||
RSA_SHA384,
|
RSA_SHA384,
|
||||||
RSA_SHA512,
|
RSA_SHA512,
|
||||||
|
SAML_BINDING_POST,
|
||||||
|
SAML_BINDING_REDIRECT,
|
||||||
SAML_NAME_ID_FORMAT_EMAIL,
|
SAML_NAME_ID_FORMAT_EMAIL,
|
||||||
SAML_NAME_ID_FORMAT_PERSISTENT,
|
SAML_NAME_ID_FORMAT_PERSISTENT,
|
||||||
SAML_NAME_ID_FORMAT_TRANSIENT,
|
SAML_NAME_ID_FORMAT_TRANSIENT,
|
||||||
@ -37,6 +39,15 @@ class SAMLBindingTypes(models.TextChoices):
|
|||||||
POST = "POST", _("POST Binding")
|
POST = "POST", _("POST Binding")
|
||||||
POST_AUTO = "POST_AUTO", _("POST Binding with auto-confirmation")
|
POST_AUTO = "POST_AUTO", _("POST Binding with auto-confirmation")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def uri(self) -> str:
|
||||||
|
"""Convert database field to URI"""
|
||||||
|
return {
|
||||||
|
SAMLBindingTypes.POST: SAML_BINDING_POST,
|
||||||
|
SAMLBindingTypes.POST_AUTO: SAML_BINDING_POST,
|
||||||
|
SAMLBindingTypes.REDIRECT: SAML_BINDING_REDIRECT,
|
||||||
|
}[self]
|
||||||
|
|
||||||
|
|
||||||
class SAMLNameIDPolicy(models.TextChoices):
|
class SAMLNameIDPolicy(models.TextChoices):
|
||||||
"""SAML NameID Policies"""
|
"""SAML NameID Policies"""
|
||||||
|
|||||||
@ -10,7 +10,7 @@ from lxml.etree import Element # nosec
|
|||||||
from authentik.providers.saml.utils import get_random_id
|
from authentik.providers.saml.utils import get_random_id
|
||||||
from authentik.providers.saml.utils.encoding import deflate_and_base64_encode
|
from authentik.providers.saml.utils.encoding import deflate_and_base64_encode
|
||||||
from authentik.providers.saml.utils.time import get_time_string
|
from authentik.providers.saml.utils.time import get_time_string
|
||||||
from authentik.sources.saml.models import SAMLSource
|
from authentik.sources.saml.models import SAMLBindingTypes, SAMLSource
|
||||||
from authentik.sources.saml.processors.constants import (
|
from authentik.sources.saml.processors.constants import (
|
||||||
DIGEST_ALGORITHM_TRANSLATION_MAP,
|
DIGEST_ALGORITHM_TRANSLATION_MAP,
|
||||||
NS_MAP,
|
NS_MAP,
|
||||||
@ -62,7 +62,7 @@ class RequestProcessor:
|
|||||||
auth_n_request.attrib["Destination"] = self.source.sso_url
|
auth_n_request.attrib["Destination"] = self.source.sso_url
|
||||||
auth_n_request.attrib["ID"] = self.request_id
|
auth_n_request.attrib["ID"] = self.request_id
|
||||||
auth_n_request.attrib["IssueInstant"] = self.issue_instant
|
auth_n_request.attrib["IssueInstant"] = self.issue_instant
|
||||||
auth_n_request.attrib["ProtocolBinding"] = self.source.binding_type
|
auth_n_request.attrib["ProtocolBinding"] = SAMLBindingTypes(self.source.binding_type).uri
|
||||||
auth_n_request.attrib["Version"] = "2.0"
|
auth_n_request.attrib["Version"] = "2.0"
|
||||||
# Create issuer object
|
# Create issuer object
|
||||||
auth_n_request.append(self.get_issuer())
|
auth_n_request.append(self.get_issuer())
|
||||||
|
|||||||
@ -61,7 +61,7 @@ class StaticDeviceViewSet(
|
|||||||
):
|
):
|
||||||
"""Viewset for static authenticator devices"""
|
"""Viewset for static authenticator devices"""
|
||||||
|
|
||||||
queryset = StaticDevice.objects.all()
|
queryset = StaticDevice.objects.filter(confirmed=True)
|
||||||
serializer_class = StaticDeviceSerializer
|
serializer_class = StaticDeviceSerializer
|
||||||
permission_classes = [OwnerPermissions]
|
permission_classes = [OwnerPermissions]
|
||||||
filter_backends = [OwnerFilter, DjangoFilterBackend, OrderingFilter, SearchFilter]
|
filter_backends = [OwnerFilter, DjangoFilterBackend, OrderingFilter, SearchFilter]
|
||||||
|
|||||||
@ -55,7 +55,7 @@ class AuthenticatorStaticStageView(ChallengeStageView):
|
|||||||
stage: AuthenticatorStaticStage = self.executor.current_stage
|
stage: AuthenticatorStaticStage = self.executor.current_stage
|
||||||
|
|
||||||
if SESSION_STATIC_DEVICE not in self.request.session:
|
if SESSION_STATIC_DEVICE not in self.request.session:
|
||||||
device = StaticDevice(user=user, confirmed=True, name="Static Token")
|
device = StaticDevice(user=user, confirmed=False, name="Static Token")
|
||||||
tokens = []
|
tokens = []
|
||||||
for _ in range(0, stage.token_count):
|
for _ in range(0, stage.token_count):
|
||||||
tokens.append(StaticToken(device=device, token=StaticToken.random_token()))
|
tokens.append(StaticToken(device=device, token=StaticToken.random_token()))
|
||||||
@ -66,6 +66,7 @@ class AuthenticatorStaticStageView(ChallengeStageView):
|
|||||||
def challenge_valid(self, response: ChallengeResponse) -> HttpResponse:
|
def challenge_valid(self, response: ChallengeResponse) -> HttpResponse:
|
||||||
"""Verify OTP Token"""
|
"""Verify OTP Token"""
|
||||||
device: StaticDevice = self.request.session[SESSION_STATIC_DEVICE]
|
device: StaticDevice = self.request.session[SESSION_STATIC_DEVICE]
|
||||||
|
device.confirmed = True
|
||||||
device.save()
|
device.save()
|
||||||
for token in self.request.session[SESSION_STATIC_TOKENS]:
|
for token in self.request.session[SESSION_STATIC_TOKENS]:
|
||||||
token.save()
|
token.save()
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user